feat(14-01): sunscreen redacting slog handler installed by every generated main
- Wrap redacts sensitive keys at any depth and scrubs Bearer, sk- and x-api-key shapes - InstallDefault is the first statement of the generated run; hello main regenerated - surf test pins that recovered panics echo no credential - sunscreen README, root modules row and the logging docs page
This commit is contained in:
@@ -99,6 +99,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
|||||||
| [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. |
|
| [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. |
|
||||||
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. |
|
| [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. |
|
||||||
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. |
|
| [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. |
|
||||||
|
| [sunscreen](modules/sunscreen/README.md) | Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs. |
|
||||||
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. |
|
| [surf](modules/surf/README.md) | HTTP routing for SummerCMS: collects plugin routes and named middleware into a `net/http` ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the `serve` and `route:list` commands. |
|
||||||
| [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. |
|
| [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. |
|
||||||
| [towel](modules/towel/README.md) | Request-scoped actor, organization, collection and locale values carried through `context.Context`. |
|
| [towel](modules/towel/README.md) | Request-scoped actor, organization, collection and locale values carried through `context.Context`. |
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Each framework module is one Go package under `modules/`, documented by its READ
|
|||||||
| HTTP | [surf](../../modules/surf/README.md), [towel](../../modules/towel/README.md), [wire](../../modules/wire/README.md), [bouncer](../../modules/bouncer/README.md), [wristband](../../modules/wristband/README.md), [fetchguard](../../modules/fetchguard/README.md) |
|
| HTTP | [surf](../../modules/surf/README.md), [towel](../../modules/towel/README.md), [wire](../../modules/wire/README.md), [bouncer](../../modules/bouncer/README.md), [wristband](../../modules/wristband/README.md), [fetchguard](../../modules/fetchguard/README.md) |
|
||||||
| Data | [lagoon](../../modules/lagoon/README.md), [beachcomber](../../modules/beachcomber/README.md) |
|
| Data | [lagoon](../../modules/lagoon/README.md), [beachcomber](../../modules/beachcomber/README.md) |
|
||||||
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md) |
|
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md) |
|
||||||
| Services | [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md) |
|
| Services | [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md), [sunscreen](../../modules/sunscreen/README.md) |
|
||||||
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md) |
|
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md) |
|
||||||
|
|
||||||
## What runs where
|
## What runs where
|
||||||
|
|||||||
50
docs/services/logging.md
Normal file
50
docs/services/logging.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Logging
|
||||||
|
description: Log through log/slog with sunscreen, the redacting handler every generated main installs, and keep tokens, keys and request bodies out of log records.
|
||||||
|
section: services
|
||||||
|
order: 105
|
||||||
|
---
|
||||||
|
# Logging
|
||||||
|
|
||||||
|
SummerCMS logs through the standard library's `log/slog`. Where a WinterCMS plugin calls `Log::error` and relies on a Monolog tap to scrub credentials, a SummerCMS plugin logs through a `*slog.Logger`, and [sunscreen](../../modules/sunscreen/README.md) does the scrubbing.
|
||||||
|
|
||||||
|
## The redacting default logger
|
||||||
|
|
||||||
|
Every application `main` that `summer build` generates calls `sunscreen.InstallDefault(os.Stderr)` as the first statement of `run`, before the configuration is loaded and before any plugin is activated. From then on `slog.Default`, the package-level `slog` functions and the standard `log` package all write through a text handler wrapped by `sunscreen.Wrap`.
|
||||||
|
|
||||||
|
The handler rewrites every record before it is written:
|
||||||
|
|
||||||
|
- The value of an attribute whose key is one of `sunscreen.RedactedKeys` becomes `[REDACTED]`, at any group depth and whatever the letter case: `api_key`, `apikey`, `authorization`, `bearer`, `password`, `secret`, `token`, `webhook_secret`, `admin_password` and the `openai_api_key`, `anthropic_api_key` and `perplexity_api_key` variants.
|
||||||
|
- The message, string values, error values and the formatted text of other values are passed through `sunscreen.Scrub`, which replaces `Bearer <token>`, `sk-` style API keys and `x-api-key: <value>`.
|
||||||
|
- `slog.LogValuer` values are resolved first, and maps such as `http.Header` are redacted key by key.
|
||||||
|
|
||||||
|
```go src=modules/sunscreen/example_test.go#ExampleWrap
|
||||||
|
// Drop the time so the output is stable; a real application keeps it.
|
||||||
|
plain := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||||
|
if len(groups) == 0 && a.Key == slog.TimeKey {
|
||||||
|
return slog.Attr{}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger := slog.New(sunscreen.Wrap(plain))
|
||||||
|
|
||||||
|
logger.Info("vendor call failed",
|
||||||
|
"api_key", "live-key-123",
|
||||||
|
slog.Group("request", slog.String("Authorization", "Bearer abc.def")),
|
||||||
|
"err", errors.New("401 for key sk-abcdefghijklmnopqrstuvwx"),
|
||||||
|
)
|
||||||
|
// Output:
|
||||||
|
// level=INFO msg="vendor call failed" api_key=[REDACTED] request.Authorization=[REDACTED] err="401 for key sk-[REDACTED]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging from a plugin
|
||||||
|
|
||||||
|
A plugin resolves a `*slog.Logger` from the application with `backpack.Lookup` and falls back to `slog.Default` when none is published, as the framework modules do. Either way the record goes through the redacting handler.
|
||||||
|
|
||||||
|
Redaction is a safety net for mistakes, not a way to log secrets on purpose. Log identifiers and outcomes: the record id, the vendor, the status code, how long it took. Never log request arguments, tokens, credentials or request and response bodies, which can carry personal data the patterns do not recognise.
|
||||||
|
|
||||||
|
## Panics
|
||||||
|
|
||||||
|
The router recovers a panicking handler and answers with an opaque 500: the JSON groups send `{"error":true,"message":"Internal server error"}` and the raw groups an empty body. The panic value never reaches the client. See [Routing](routing.md).
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/party"
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||||
"git.golem15.com/golem15/summercms/modules/surf"
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run(args []string, out io.Writer) error {
|
func run(args []string, out io.Writer) error {
|
||||||
|
sunscreen.InstallDefault(os.Stderr)
|
||||||
cfg, err := compass.Load("config")
|
cfg, err := compass.Load("config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func generateMain(m Manifest) ([]byte, error) {
|
|||||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/lagoon\"\n")
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/lagoon\"\n")
|
||||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/pact\"\n")
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/pact\"\n")
|
||||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/party\"\n")
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/party\"\n")
|
||||||
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/sunscreen\"\n")
|
||||||
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/surf\"\n")
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/surf\"\n")
|
||||||
b.WriteString(")\n\n")
|
b.WriteString(")\n\n")
|
||||||
b.WriteString("func main() {\n")
|
b.WriteString("func main() {\n")
|
||||||
@@ -102,6 +103,7 @@ func generateMain(m Manifest) ([]byte, error) {
|
|||||||
b.WriteString("\t}\n")
|
b.WriteString("\t}\n")
|
||||||
b.WriteString("}\n\n")
|
b.WriteString("}\n\n")
|
||||||
b.WriteString("func run(args []string, out io.Writer) error {\n")
|
b.WriteString("func run(args []string, out io.Writer) error {\n")
|
||||||
|
b.WriteString("\tsunscreen.InstallDefault(os.Stderr)\n")
|
||||||
b.WriteString("\tcfg, err := compass.Load(\"config\")\n")
|
b.WriteString("\tcfg, err := compass.Load(\"config\")\n")
|
||||||
b.WriteString("\tif err != nil {\n")
|
b.WriteString("\tif err != nil {\n")
|
||||||
b.WriteString("\t\treturn err\n")
|
b.WriteString("\t\treturn err\n")
|
||||||
|
|||||||
@@ -142,6 +142,32 @@ func TestGenerateMainRegistersCongaRuntimeCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGenerateMainInstallsRedactingLogger: the first statement of run makes
|
||||||
|
// the credential-redacting handler the default logger, before config loads
|
||||||
|
// or any plugin can log.
|
||||||
|
func TestGenerateMainInstallsRedactingLogger(t *testing.T) {
|
||||||
|
mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
line := []byte("sunscreen.InstallDefault(os.Stderr)")
|
||||||
|
if n := bytes.Count(mainSrc, line); n != 1 {
|
||||||
|
t.Fatalf("InstallDefault count = %d, want 1\n%s", n, mainSrc)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/sunscreen"`)) {
|
||||||
|
t.Fatalf("generated main does not import sunscreen:\n%s", mainSrc)
|
||||||
|
}
|
||||||
|
install := bytes.Index(mainSrc, line)
|
||||||
|
runStart := bytes.Index(mainSrc, []byte("func run(args []string, out io.Writer) error {"))
|
||||||
|
load := bytes.Index(mainSrc, []byte("compass.Load("))
|
||||||
|
if runStart < 0 || load < 0 || !(runStart < install && install < load) {
|
||||||
|
t.Fatalf("InstallDefault must be the first statement of run, before compass.Load:\n%s", mainSrc)
|
||||||
|
}
|
||||||
|
if between := bytes.TrimSpace(mainSrc[runStart+len("func run(args []string, out io.Writer) error {") : install]); len(between) != 0 {
|
||||||
|
t.Fatalf("statements before InstallDefault: %q", between)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGenerateMainPublishesCommandCatalog: the generated main publishes the
|
// TestGenerateMainPublishesCommandCatalog: the generated main publishes the
|
||||||
// final command list as a *bonfire.Catalog after collecting plugin commands
|
// final command list as a *bonfire.Catalog after collecting plugin commands
|
||||||
// and before building the root, so scheduled runs can call any command.
|
// and before building the root, so scheduled runs can call any command.
|
||||||
|
|||||||
63
modules/sunscreen/README.md
Normal file
63
modules/sunscreen/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# sunscreen
|
||||||
|
|
||||||
|
Credential-redacting slog handler that keeps API keys, tokens and passwords out of application logs.
|
||||||
|
|
||||||
|
`import "git.golem15.com/golem15/summercms/modules/sunscreen"`
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`sunscreen` wraps any `log/slog` handler and rewrites every record before it is written: values of sensitive attribute keys become `[REDACTED]`, and credential shapes inside messages and string values are scrubbed. It is the SummerCMS counterpart of a Monolog tap that a WinterCMS application wires into its log channels to scrub contexts and messages. Every application `main` that `summer build` generates calls `sunscreen.InstallDefault(os.Stderr)` as its first statement, so the default logger, the standard `log` package and every plugin that falls back to `slog.Default` log through it from the start.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Sensitive keys (`sunscreen.RedactedKeys`): `api_key`, `apikey`, `authorization`, `bearer`, `password`, `secret`, `token`, `webhook_secret`, `admin_password`, `openai_api_key`, `anthropic_api_key` and `perplexity_api_key`, compared case-insensitively at any group depth. The value of such an attribute becomes `sunscreen.Redacted`, whatever its kind.
|
||||||
|
- Scrub patterns (`sunscreen.Scrub`): `Bearer <token>` becomes `Bearer [REDACTED]`, `sk-` followed by 20 or more key characters (dashed keys included) becomes `sk-[REDACTED]`, and `x-api-key: <value>` becomes `x-api-key: [REDACTED]`. The Bearer and x-api-key matches are case-insensitive.
|
||||||
|
- Applied to the record message, string values, error values, byte slices and the formatted text of any other value; maps with string keys, such as `http.Header`, are redacted key by key.
|
||||||
|
- `slog.LogValuer` values are resolved before redaction, groups are walked recursively, and attributes added with `WithAttrs` are redacted before they reach the wrapped handler.
|
||||||
|
- `sunscreen.InstallDefault` builds a fresh text handler instead of wrapping the existing default, whose output goes through the `log` package that `slog.SetDefault` redirects back into the new handler.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The generated `main` already installs it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func run(args []string, out io.Writer) error {
|
||||||
|
sunscreen.InstallDefault(os.Stderr)
|
||||||
|
cfg, err := compass.Load("config")
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To redact another handler, for example a JSON handler in a custom binary, wrap it:
|
||||||
|
|
||||||
|
```go
|
||||||
|
logger := slog.New(sunscreen.Wrap(slog.NewJSONHandler(os.Stderr, nil)))
|
||||||
|
logger.Error("vendor call failed", "api_key", key, "err", err)
|
||||||
|
// {"level":"ERROR","msg":"vendor call failed","api_key":"[REDACTED]","err":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
Redaction is a safety net, not a licence: log ids and outcomes, never request bodies, tokens or arguments that carry them.
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
| Identifier | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `sunscreen.Wrap` | Returns a `slog.Handler` that redacts every record and every `WithAttrs` attribute before the wrapped handler sees it. Wrapping a sunscreen handler again returns it unchanged. |
|
||||||
|
| `sunscreen.InstallDefault` | Makes a redacting text handler writing to the given writer the process default logger. |
|
||||||
|
| `sunscreen.Scrub` | Replaces the Bearer, `sk-` and `x-api-key:` credential shapes in a string. |
|
||||||
|
| `sunscreen.Redacted` | The replacement text, `[REDACTED]`. |
|
||||||
|
| `sunscreen.RedactedKeys` | Returns a copy of the attribute keys whose values are always redacted. |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- SummerCMS modules: none.
|
||||||
|
- Third-party: none.
|
||||||
|
- Standard library: `context`, `fmt`, `io`, `log/slog`, `reflect`, `regexp`, `slices`, `strings`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./modules/sunscreen/...
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests log through the handler into a buffer and need no external services.
|
||||||
30
modules/sunscreen/example_test.go
Normal file
30
modules/sunscreen/example_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package sunscreen_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleWrap() {
|
||||||
|
// Drop the time so the output is stable; a real application keeps it.
|
||||||
|
plain := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||||
|
if len(groups) == 0 && a.Key == slog.TimeKey {
|
||||||
|
return slog.Attr{}
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger := slog.New(sunscreen.Wrap(plain))
|
||||||
|
|
||||||
|
logger.Info("vendor call failed",
|
||||||
|
"api_key", "live-key-123",
|
||||||
|
slog.Group("request", slog.String("Authorization", "Bearer abc.def")),
|
||||||
|
"err", errors.New("401 for key sk-abcdefghijklmnopqrstuvwx"),
|
||||||
|
)
|
||||||
|
// Output:
|
||||||
|
// level=INFO msg="vendor call failed" api_key=[REDACTED] request.Authorization=[REDACTED] err="401 for key sk-[REDACTED]"
|
||||||
|
}
|
||||||
198
modules/sunscreen/sunscreen.go
Normal file
198
modules/sunscreen/sunscreen.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Package sunscreen is a credential-redacting slog handler that keeps API
|
||||||
|
// keys, tokens and passwords out of application logs.
|
||||||
|
package sunscreen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Redacted replaces the value of a sensitive attribute and the secret part
|
||||||
|
// of a scrubbed string.
|
||||||
|
const Redacted = "[REDACTED]"
|
||||||
|
|
||||||
|
var redactedKeys = []string{
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"authorization",
|
||||||
|
"bearer",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
"webhook_secret",
|
||||||
|
"admin_password",
|
||||||
|
"openai_api_key",
|
||||||
|
"anthropic_api_key",
|
||||||
|
"perplexity_api_key",
|
||||||
|
}
|
||||||
|
|
||||||
|
var keySet = func() map[string]struct{} {
|
||||||
|
m := make(map[string]struct{}, len(redactedKeys))
|
||||||
|
for _, k := range redactedKeys {
|
||||||
|
m[k] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// RedactedKeys returns the attribute keys whose values are always replaced
|
||||||
|
// with Redacted. Keys are compared case-insensitively, at any group depth.
|
||||||
|
func RedactedKeys() []string {
|
||||||
|
return slices.Clone(redactedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sensitive(key string) bool {
|
||||||
|
_, ok := keySet[strings.ToLower(key)]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
var patterns = []struct {
|
||||||
|
re *regexp.Regexp
|
||||||
|
repl string
|
||||||
|
}{
|
||||||
|
{regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-+/=]+`), "Bearer " + Redacted},
|
||||||
|
// Also covers dashed keys such as sk-ant-..., which the reference
|
||||||
|
// pattern sk-[A-Za-z0-9]{20,} misses.
|
||||||
|
{regexp.MustCompile(`sk-[A-Za-z0-9_\-]{20,}`), "sk-" + Redacted},
|
||||||
|
{regexp.MustCompile(`(?i)x-api-key:\s*[^\s,]+`), "x-api-key: " + Redacted},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub replaces credential shapes in s: "Bearer <token>" becomes
|
||||||
|
// "Bearer [REDACTED]", "sk-" followed by 20 or more key characters becomes
|
||||||
|
// "sk-[REDACTED]", and "x-api-key: <value>" becomes "x-api-key: [REDACTED]".
|
||||||
|
// The Bearer and x-api-key matches are case-insensitive.
|
||||||
|
func Scrub(s string) string {
|
||||||
|
for _, p := range patterns {
|
||||||
|
s = p.re.ReplaceAllString(s, p.repl)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap returns a handler that redacts every record before next sees it: the
|
||||||
|
// message is scrubbed; attributes whose key is one of RedactedKeys get the
|
||||||
|
// value Redacted, at any group depth; LogValuer values are resolved first;
|
||||||
|
// string values, error values and other formatted values are scrubbed; maps
|
||||||
|
// with string keys are redacted key by key. Attributes added with WithAttrs
|
||||||
|
// are redacted the same way before they reach next.
|
||||||
|
func Wrap(next slog.Handler) slog.Handler {
|
||||||
|
if h, ok := next.(*handler); ok {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return &handler{next: next}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallDefault makes a redacting text handler writing to w the process
|
||||||
|
// default logger (slog.SetDefault), so plugins that fall back to
|
||||||
|
// slog.Default and the standard log package both log through it. It builds
|
||||||
|
// a fresh slog.TextHandler instead of wrapping the existing default, whose
|
||||||
|
// output goes through the log package that SetDefault redirects back here.
|
||||||
|
func InstallDefault(w io.Writer) {
|
||||||
|
slog.SetDefault(slog.New(Wrap(slog.NewTextHandler(w, nil))))
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
next slog.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||||
|
return h.next.Enabled(ctx, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) Handle(ctx context.Context, r slog.Record) error {
|
||||||
|
out := slog.NewRecord(r.Time, r.Level, Scrub(r.Message), r.PC)
|
||||||
|
r.Attrs(func(a slog.Attr) bool {
|
||||||
|
out.AddAttrs(redactAttr(a))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return h.next.Handle(ctx, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
red := make([]slog.Attr, len(attrs))
|
||||||
|
for i, a := range attrs {
|
||||||
|
red[i] = redactAttr(a)
|
||||||
|
}
|
||||||
|
return &handler{next: h.next.WithAttrs(red)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) WithGroup(name string) slog.Handler {
|
||||||
|
return &handler{next: h.next.WithGroup(name)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactAttr(a slog.Attr) slog.Attr {
|
||||||
|
if sensitive(a.Key) {
|
||||||
|
return slog.String(a.Key, Redacted)
|
||||||
|
}
|
||||||
|
v := a.Value.Resolve()
|
||||||
|
switch v.Kind() {
|
||||||
|
case slog.KindGroup:
|
||||||
|
group := v.Group()
|
||||||
|
out := make([]slog.Attr, len(group))
|
||||||
|
for i, g := range group {
|
||||||
|
out[i] = redactAttr(g)
|
||||||
|
}
|
||||||
|
return slog.Attr{Key: a.Key, Value: slog.GroupValue(out...)}
|
||||||
|
case slog.KindString:
|
||||||
|
return slog.String(a.Key, Scrub(v.String()))
|
||||||
|
case slog.KindAny:
|
||||||
|
return slog.Attr{Key: a.Key, Value: redactAny(v.Any())}
|
||||||
|
default:
|
||||||
|
return slog.Attr{Key: a.Key, Value: v}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactAny(x any) slog.Value {
|
||||||
|
switch t := x.(type) {
|
||||||
|
case nil:
|
||||||
|
return slog.AnyValue(nil)
|
||||||
|
case error:
|
||||||
|
return slog.StringValue(Scrub(t.Error()))
|
||||||
|
case []byte:
|
||||||
|
return slog.StringValue(Scrub(string(t)))
|
||||||
|
}
|
||||||
|
if m, ok := redactMap(x); ok {
|
||||||
|
return slog.AnyValue(m)
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("%+v", x)
|
||||||
|
if scrubbed := Scrub(text); scrubbed != text {
|
||||||
|
return slog.StringValue(scrubbed)
|
||||||
|
}
|
||||||
|
return slog.AnyValue(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactMap copies a map with string keys (including http.Header and other
|
||||||
|
// named map types), redacting sensitive keys and scrubbing the rest.
|
||||||
|
func redactMap(x any) (map[string]any, bool) {
|
||||||
|
rv := reflect.ValueOf(x)
|
||||||
|
if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := make(map[string]any, rv.Len())
|
||||||
|
iter := rv.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
k := iter.Key().String()
|
||||||
|
if sensitive(k) {
|
||||||
|
out[k] = Redacted
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := iter.Value().Interface()
|
||||||
|
switch t := val.(type) {
|
||||||
|
case string:
|
||||||
|
out[k] = Scrub(t)
|
||||||
|
case []string:
|
||||||
|
s := make([]string, len(t))
|
||||||
|
for i, e := range t {
|
||||||
|
s[i] = Scrub(e)
|
||||||
|
}
|
||||||
|
out[k] = s
|
||||||
|
default:
|
||||||
|
out[k] = redactAny(val).Any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
144
modules/sunscreen/sunscreen_test.go
Normal file
144
modules/sunscreen/sunscreen_test.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package sunscreen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
skKey = "sk-abcdefghijklmnopqrstuvwxyz123456"
|
||||||
|
antKey = "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
|
||||||
|
bearer = "eyJhbGciOiJIUzI1NiJ9.payload.sig"
|
||||||
|
apiKey = "live-api-key-value"
|
||||||
|
passwd = "hunter2-secret-pass"
|
||||||
|
nested = "nested-token-value"
|
||||||
|
valuerV = "valuer-secret-value"
|
||||||
|
)
|
||||||
|
|
||||||
|
type secretValuer struct{}
|
||||||
|
|
||||||
|
func (secretValuer) LogValue() slog.Value {
|
||||||
|
return slog.GroupValue(slog.String("Token", valuerV), slog.String("name", "alice"))
|
||||||
|
}
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Endpoint string
|
||||||
|
Header string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestLogger(buf *bytes.Buffer) *slog.Logger {
|
||||||
|
return slog.New(Wrap(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactHandler(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := newTestLogger(&buf)
|
||||||
|
|
||||||
|
logger.With("API_KEY", apiKey, slog.Group("ctx", slog.String("password", passwd))).
|
||||||
|
WithGroup("req").
|
||||||
|
Info("calling vendor with Bearer "+bearer,
|
||||||
|
"Authorization", "Bearer "+bearer,
|
||||||
|
"user", 7,
|
||||||
|
slog.Group("deep", slog.Group("deeper", slog.String("Secret", nested), slog.String("note", "key "+skKey))),
|
||||||
|
"valuer", secretValuer{},
|
||||||
|
"err", errors.New("upstream said: x-api-key: "+apiKey+", retry"),
|
||||||
|
"headers", http.Header{"Authorization": {"Bearer " + bearer}, "Accept": {"application/json"}},
|
||||||
|
"payload", map[string]any{"OpenAI_API_Key": skKey, "inner": map[string]string{"webhook_secret": nested, "ok": "fine"}},
|
||||||
|
"cfg", config{Endpoint: "https://api.example.com", Header: "x-api-key: " + apiKey},
|
||||||
|
"raw", []byte("token sk-"+strings.Repeat("Z", 24)),
|
||||||
|
"anthropic", antKey,
|
||||||
|
)
|
||||||
|
out := buf.String()
|
||||||
|
for _, secret := range []string{skKey, antKey, bearer, apiKey, passwd, nested, valuerV, strings.Repeat("Z", 24)} {
|
||||||
|
if strings.Contains(out, secret) {
|
||||||
|
t.Fatalf("log leaks %q:\n%s", secret, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"API_KEY=[REDACTED]",
|
||||||
|
"ctx.password=[REDACTED]",
|
||||||
|
"req.Authorization=[REDACTED]",
|
||||||
|
"req.user=7",
|
||||||
|
"req.deep.deeper.Secret=[REDACTED]",
|
||||||
|
`req.deep.deeper.note="key sk-[REDACTED]"`,
|
||||||
|
"req.valuer.Token=[REDACTED]",
|
||||||
|
"req.valuer.name=alice",
|
||||||
|
`x-api-key: [REDACTED]`,
|
||||||
|
"Bearer [REDACTED]",
|
||||||
|
"ok:fine",
|
||||||
|
"https://api.example.com",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("log missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled follows the wrapped handler; wrapping twice is a no-op.
|
||||||
|
quiet := Wrap(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
if quiet.Enabled(t.Context(), slog.LevelInfo) || !quiet.Enabled(t.Context(), slog.LevelError) {
|
||||||
|
t.Fatal("Enabled must follow the wrapped handler")
|
||||||
|
}
|
||||||
|
if Wrap(quiet) != quiet {
|
||||||
|
t.Fatal("Wrap of a sunscreen handler must return it unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := RedactedKeys()
|
||||||
|
keys[0] = "mutated"
|
||||||
|
if RedactedKeys()[0] != "api_key" || len(keys) != 12 {
|
||||||
|
t.Fatalf("RedactedKeys must return a copy of the 12 keys: %v", RedactedKeys())
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
logger.Info("plain", "nil", nil, "count", 3, "ok", true)
|
||||||
|
if !strings.Contains(buf.String(), "nil=<nil>") || !strings.Contains(buf.String(), "count=3") {
|
||||||
|
t.Fatalf("non-secret values changed: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrub(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"Authorization: Bearer abc.DEF-123_+/=", "Authorization: Bearer [REDACTED]"},
|
||||||
|
{"bearer lower.case.token", "Bearer [REDACTED]"},
|
||||||
|
{"key=" + skKey + " end", "key=sk-[REDACTED] end"},
|
||||||
|
{antKey, "sk-[REDACTED]"},
|
||||||
|
{"sk-short", "sk-short"},
|
||||||
|
{"X-API-KEY: abc123, next", "x-api-key: [REDACTED], next"},
|
||||||
|
{"x-api-key:abc", "x-api-key: [REDACTED]"},
|
||||||
|
{"nothing secret here: 42 albums", "nothing secret here: 42 albums"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := Scrub(c.in); got != c.want {
|
||||||
|
t.Errorf("Scrub(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Scrub(Scrub("Bearer abc")) != "Bearer [REDACTED]" {
|
||||||
|
t.Fatal("Scrub must be idempotent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallDefault(t *testing.T) {
|
||||||
|
prev := slog.Default()
|
||||||
|
prevFlags, prevWriter := log.Flags(), log.Writer()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
slog.SetDefault(prev)
|
||||||
|
log.SetFlags(prevFlags)
|
||||||
|
log.SetOutput(prevWriter)
|
||||||
|
})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
InstallDefault(&buf)
|
||||||
|
slog.Info("login", "password", passwd)
|
||||||
|
log.Printf("legacy log with Bearer %s", bearer)
|
||||||
|
out := buf.String()
|
||||||
|
if strings.Contains(out, passwd) || strings.Contains(out, bearer) {
|
||||||
|
t.Fatalf("default logger leaks:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "password=[REDACTED]") || !strings.Contains(out, "Bearer [REDACTED]") {
|
||||||
|
t.Fatalf("default logger output:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
modules/surf/recover_redaction_test.go
Normal file
74
modules/surf/recover_redaction_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package surf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRecoverHidesPanicDetails pins the SafeExceptionResponse behaviour: a
|
||||||
|
// panicking handler's message, here carrying credentials, never reaches the
|
||||||
|
// response in either group type, and a log record of the panic written
|
||||||
|
// through a sunscreen handler carries neither credential.
|
||||||
|
func TestRecoverHidesPanicDetails(t *testing.T) {
|
||||||
|
const skKey = "sk-abcdefghijklmnopqrstuvwxyz0123"
|
||||||
|
const token = "eyJhbGciOiJIUzI1NiJ9.claims.signature"
|
||||||
|
panicErr := errors.New("vendor rejected key " + skKey + " with Authorization: Bearer " + token)
|
||||||
|
|
||||||
|
prev := slog.Default()
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
var logs bytes.Buffer
|
||||||
|
sunscreen.InstallDefault(&logs)
|
||||||
|
|
||||||
|
r := New(nil)
|
||||||
|
r.GroupRaw("/oauth", nil, func(g pact.Router) {
|
||||||
|
g.Post("/token", func(http.ResponseWriter, *http.Request) { panic(panicErr) })
|
||||||
|
})
|
||||||
|
r.Post("/api/v1/recognize", func(http.ResponseWriter, *http.Request) { panic(panicErr) })
|
||||||
|
h, err := r.compile()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path, body, contentType string
|
||||||
|
}{
|
||||||
|
{"/api/v1/recognize", `{"error":true,"message":"Internal server error"}`, "application/json"},
|
||||||
|
{"/oauth/token", "", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, c.path, nil))
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("%s status = %d", c.path, rec.Code)
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(rec.Body.String())
|
||||||
|
if body != c.body {
|
||||||
|
t.Fatalf("%s body = %q, want the opaque %q", c.path, body, c.body)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != c.contentType {
|
||||||
|
t.Fatalf("%s Content-Type = %q", c.path, got)
|
||||||
|
}
|
||||||
|
for _, secret := range []string{skKey, token, "vendor rejected"} {
|
||||||
|
if strings.Contains(rec.Body.String(), secret) {
|
||||||
|
t.Fatalf("%s response echoes %q", c.path, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("handler panicked", "err", panicErr, "path", "/api/v1/recognize")
|
||||||
|
out := logs.String()
|
||||||
|
if strings.Contains(out, skKey) || strings.Contains(out, token) {
|
||||||
|
t.Fatalf("log record leaks a credential:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "sk-[REDACTED]") || !strings.Contains(out, "Bearer [REDACTED]") {
|
||||||
|
t.Fatalf("log record not scrubbed as expected:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user