mirror of
https://github.com/versity/versitygw.git
synced 2026-08-21 22:56:28 +00:00
feat: add STS web identity federation, IAM policy Condition support, and access control enforcement
Implements the `AssumeRoleWithWebIdentity` and `GetCallerIdentity` STS actions, letting callers exchange an external OIDC token for temporary credentials scoped to an IAM role. Token handling covers JWT claim parsing, issuer/audience resolution (including `azp` override semantics), JWKS fetching and caching with `singleflight`-deduplicated refresh, and rate-limited forced refresh on unrecognized `kid` values. OIDC provider thumbprint fetching now performs a real TLS handshake verified against the system trust store and the provider hostname (previously `InsecureSkipVerify`), since the observed certificate is persisted as a long-lived trust anchor rather than used once and discarded; all discovery-document and JWKS fetches go through an SSRF-safe HTTP client with bounded redirects and response size.
Adds policy `Condition` block evaluation, supporting `String`, `Numeric`, `Date`, `Bool`, `BinaryEquals`, and `IpAddress` operators along with their `IfExists`/`Not` variants and `ForAllValues`/`ForAnyValues` set qualifiers, plus policy variable substitution (e.g. `${aws:username}`) in supported operators. Adds identity-based inline policy evaluation and a new IAM authorization middleware that authorizes each request against action, resource, and condition context together, applying the session-policy-intersects-role-policy semantics for assumed-role sessions.
Adds a new debug logger `--log-level` flag (`silent`/`debug`/`unsafe`), along with a tree-based XML masker that redacts secrets and tokens at the property level in logged request/response bodies instead of skipping the whole body. The old `--debug/VGW_DEBUG` flag is kept as a deprecated alias for `--log-level=debug`, printing a console warning that points users at `--log-level` for finer-grained control.
Fixes a Vault storage bug where CAS (check-and-set) writes always read the current document version as 0 because `kvVersion` asserted metadata as `float64` while the Vault client actually returns `json.Number`, causing every write past the first to be rejected as a concurrent modification. Also adds a constant-time `SecureCompare` for signature/token comparisons in sigv4 auth.
Adds an integration test suite (`iam_access_control.go`) covering IAM access control across user, role, and session identities.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package debuglogger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Level controls both whether the debug logger produces any output and,
|
||||
// when it does, whether secrets and tokens embedded in that output are
|
||||
// masked.
|
||||
type Level int32
|
||||
|
||||
const (
|
||||
// LevelSilent prints no debug logs. This is the default.
|
||||
LevelSilent Level = iota
|
||||
// LevelDebug prints full request/response logs with secrets and
|
||||
// tokens (access keys, session tokens, signatures, ...) masked.
|
||||
LevelDebug
|
||||
// LevelUnsafe prints full request/response logs with secrets and
|
||||
// tokens shown in the clear. Anyone with access to this output can
|
||||
// read and replay credentials directly; never use in production.
|
||||
LevelUnsafe
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelSilent:
|
||||
return "silent"
|
||||
case LevelDebug:
|
||||
return "debug"
|
||||
case LevelUnsafe:
|
||||
return "unsafe"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel parses "silent", "debug", or "unsafe" (case-insensitive) into
|
||||
// a Level. An empty string parses as LevelSilent.
|
||||
func ParseLevel(s string) (Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "silent":
|
||||
return LevelSilent, nil
|
||||
case "debug":
|
||||
return LevelDebug, nil
|
||||
case "unsafe":
|
||||
return LevelUnsafe, nil
|
||||
default:
|
||||
return LevelSilent, fmt.Errorf("invalid log level %q: must be one of 'silent', 'debug', 'unsafe'", s)
|
||||
}
|
||||
}
|
||||
|
||||
var currentLevel atomic.Int32
|
||||
|
||||
// SetLevel sets the active debug log level.
|
||||
func SetLevel(l Level) {
|
||||
currentLevel.Store(int32(l))
|
||||
}
|
||||
|
||||
// CurrentLevel returns the active debug log level.
|
||||
func CurrentLevel() Level {
|
||||
return Level(currentLevel.Load())
|
||||
}
|
||||
|
||||
// IsDebugEnabled returns true when the debug logger produces output, at
|
||||
// either LevelDebug or LevelUnsafe.
|
||||
func IsDebugEnabled() bool {
|
||||
return CurrentLevel() != LevelSilent
|
||||
}
|
||||
|
||||
// IsUnsafeEnabled returns true when the debug logger is configured to print
|
||||
// secrets and tokens without masking.
|
||||
func IsUnsafeEnabled() bool {
|
||||
return CurrentLevel() == LevelUnsafe
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package debuglogger
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want Level
|
||||
wantErr bool
|
||||
}{
|
||||
{"silent", LevelSilent, false},
|
||||
{"", LevelSilent, false},
|
||||
{"SILENT", LevelSilent, false},
|
||||
{"debug", LevelDebug, false},
|
||||
{" Debug ", LevelDebug, false},
|
||||
{"unsafe", LevelUnsafe, false},
|
||||
{"UNSAFE", LevelUnsafe, false},
|
||||
{"verbose", LevelSilent, true},
|
||||
{"true", LevelSilent, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := ParseLevel(tt.in)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseLevel(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
|
||||
continue
|
||||
}
|
||||
if err == nil && got != tt.want {
|
||||
t.Errorf("ParseLevel(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelGatesDebugAndUnsafe(t *testing.T) {
|
||||
defer SetLevel(LevelSilent)
|
||||
|
||||
SetLevel(LevelSilent)
|
||||
if IsDebugEnabled() {
|
||||
t.Error("IsDebugEnabled() at LevelSilent = true, want false")
|
||||
}
|
||||
if IsUnsafeEnabled() {
|
||||
t.Error("IsUnsafeEnabled() at LevelSilent = true, want false")
|
||||
}
|
||||
|
||||
SetLevel(LevelDebug)
|
||||
if !IsDebugEnabled() {
|
||||
t.Error("IsDebugEnabled() at LevelDebug = false, want true")
|
||||
}
|
||||
if IsUnsafeEnabled() {
|
||||
t.Error("IsUnsafeEnabled() at LevelDebug = true, want false")
|
||||
}
|
||||
|
||||
SetLevel(LevelUnsafe)
|
||||
if !IsDebugEnabled() {
|
||||
t.Error("IsDebugEnabled() at LevelUnsafe = false, want true")
|
||||
}
|
||||
if !IsUnsafeEnabled() {
|
||||
t.Error("IsUnsafeEnabled() at LevelUnsafe = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIAMDebugEnabledRequiresBothLevelAndIAMFlag(t *testing.T) {
|
||||
defer func() {
|
||||
SetLevel(LevelSilent)
|
||||
debugIAMEnabled.Store(false)
|
||||
}()
|
||||
|
||||
SetLevel(LevelSilent)
|
||||
debugIAMEnabled.Store(true)
|
||||
if IsIAMDebugEnabled() {
|
||||
t.Error("IsIAMDebugEnabled() with iam-debug set but level silent = true, want false")
|
||||
}
|
||||
|
||||
SetLevel(LevelDebug)
|
||||
debugIAMEnabled.Store(false)
|
||||
if IsIAMDebugEnabled() {
|
||||
t.Error("IsIAMDebugEnabled() with level debug but iam-debug unset = true, want false")
|
||||
}
|
||||
|
||||
debugIAMEnabled.Store(true)
|
||||
if !IsIAMDebugEnabled() {
|
||||
t.Error("IsIAMDebugEnabled() with level debug and iam-debug set = false, want true")
|
||||
}
|
||||
}
|
||||
+41
-25
@@ -64,30 +64,46 @@ func printError(prefix prefix, er error) {
|
||||
|
||||
// Logs http request details: headers, body, params, query args
|
||||
func LogFiberRequestDetails(ctx fiber.Ctx) {
|
||||
// Log the full request url
|
||||
fullURL := ctx.Scheme() + "://" + ctx.Host() + ctx.OriginalURL()
|
||||
// Log the full request url, with sensitive query parameter values
|
||||
// redacted (ctx.OriginalURL() would print them in the clear).
|
||||
fullURL := ctx.Scheme() + "://" + ctx.Host() + ctx.Path()
|
||||
if qs := debugRedactedQueryString(ctx.Request().URI().QueryArgs()); qs != "" {
|
||||
fullURL += "?" + qs
|
||||
}
|
||||
fmt.Printf("%s[URL]: %s%s\n", green, fullURL, reset)
|
||||
|
||||
// log request headers
|
||||
wrapInBox(green, "REQUEST HEADERS", boxWidth, func() {
|
||||
for key, value := range ctx.Request().Header.All() {
|
||||
printWrappedLine(yellow, string(key), string(value))
|
||||
printWrappedLine(yellow, string(key), debugRedact(string(key), string(value)))
|
||||
}
|
||||
})
|
||||
// skip request body log for PutObject and UploadPart
|
||||
skipBodyLog := isLargeDataAction(ctx)
|
||||
if !skipBodyLog {
|
||||
body := ctx.Request().Body()
|
||||
if len(body) != 0 {
|
||||
if postArgs := ctx.Request().PostArgs(); postArgs.Len() != 0 {
|
||||
// form-encoded body (e.g. AWS Query protocol requests like
|
||||
// IAM/STS): log key=value pairs so sensitive fields (e.g.
|
||||
// WebIdentityToken) can be redacted individually, instead of
|
||||
// printing the raw, still-encoded body bytes.
|
||||
printBoxTitleLine(blue, "REQUEST BODY", boxWidth, false)
|
||||
fmt.Printf("%s%s%s\n", blue, body, reset)
|
||||
for key, value := range postArgs.All() {
|
||||
fmt.Printf("%s%s=%s%s\n", blue, key, debugRedact(string(key), string(value)), reset)
|
||||
}
|
||||
printHorizontalBorder(blue, boxWidth, false)
|
||||
} else {
|
||||
body := ctx.Request().Body()
|
||||
if len(body) != 0 {
|
||||
printBoxTitleLine(blue, "REQUEST BODY", boxWidth, false)
|
||||
fmt.Printf("%s%s%s\n", blue, formatBodyForLog(body), reset)
|
||||
printHorizontalBorder(blue, boxWidth, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Request().URI().QueryArgs().Len() != 0 {
|
||||
for key, value := range ctx.Request().URI().QueryArgs().All() {
|
||||
log.Printf("%s: %s", key, value)
|
||||
log.Printf("%s: %s", key, debugRedact(string(key), string(value)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +112,7 @@ func LogFiberRequestDetails(ctx fiber.Ctx) {
|
||||
func LogFiberResponseDetails(ctx fiber.Ctx) {
|
||||
wrapInBox(green, "RESPONSE HEADERS", boxWidth, func() {
|
||||
for key, value := range ctx.Response().Header.All() {
|
||||
printWrappedLine(yellow, string(key), string(value))
|
||||
printWrappedLine(yellow, string(key), debugRedact(string(key), string(value)))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -104,27 +120,26 @@ func LogFiberResponseDetails(ctx fiber.Ctx) {
|
||||
if !ok {
|
||||
body := ctx.Response().Body()
|
||||
if len(body) != 0 {
|
||||
PrintInsideHorizontalBorders(blue, "RESPONSE BODY", string(body), boxWidth)
|
||||
PrintInsideHorizontalBorders(blue, "RESPONSE BODY", formatBodyForLog(body), boxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var debugEnabled atomic.Bool
|
||||
|
||||
// SetDebugEnabled sets the debug mode
|
||||
func SetDebugEnabled() {
|
||||
debugEnabled.Store(true)
|
||||
}
|
||||
|
||||
// IsDebugEnabled returns true if debugging is enabled
|
||||
func IsDebugEnabled() bool {
|
||||
return debugEnabled.Load()
|
||||
// formatBodyForLog returns body pretty-printed with property-level secret
|
||||
// masking when it parses as XML (the case for every S3 and IAM API request
|
||||
// or response body reaching this point), and the raw body unchanged
|
||||
// otherwise. Masking is skipped entirely at LevelUnsafe.
|
||||
func formatBodyForLog(body []byte) string {
|
||||
if masked, ok := maskXMLBody(body); ok {
|
||||
return string(masked)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// Logf is the same as 'fmt.Printf' with debug prefix,
|
||||
// a color added and '\n' at the end
|
||||
func Logf(format string, v ...any) {
|
||||
if !debugEnabled.Load() {
|
||||
if !IsDebugEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,7 +148,7 @@ func Logf(format string, v ...any) {
|
||||
|
||||
// Infof prints out green info block with [INFO]: prefix
|
||||
func Infof(format string, v ...any) {
|
||||
if !debugEnabled.Load() {
|
||||
if !IsDebugEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -147,15 +162,16 @@ func SetIAMDebugEnabled() {
|
||||
debugIAMEnabled.Store(true)
|
||||
}
|
||||
|
||||
// IsDebugEnabled returns true if debugging enabled
|
||||
// IsIAMDebugEnabled returns true if IAM subsystem debugging is enabled: the
|
||||
// --iam-debug flag was set and the log level is not silent.
|
||||
func IsIAMDebugEnabled() bool {
|
||||
return debugEnabled.Load()
|
||||
return IsDebugEnabled() && debugIAMEnabled.Load()
|
||||
}
|
||||
|
||||
// IAMLogf is the same as 'fmt.Printf' with debug prefix,
|
||||
// a color added and '\n' at the end
|
||||
func IAMLogf(format string, v ...any) {
|
||||
if !debugIAMEnabled.Load() {
|
||||
if !IsIAMDebugEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -165,7 +181,7 @@ func IAMLogf(format string, v ...any) {
|
||||
// PrintInsideHorizontalBorders prints the text inside horizontal
|
||||
// border and title in the center of upper border
|
||||
func PrintInsideHorizontalBorders(color Color, title, text string, width int) {
|
||||
if !debugEnabled.Load() {
|
||||
if !IsDebugEnabled() {
|
||||
return
|
||||
}
|
||||
printBoxTitleLine(color, title, width, false)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package debuglogger
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// redactedValue replaces the value of a matched sensitive field entirely.
|
||||
// The debug logger uses the same mask character for the partial masking
|
||||
// applied to fields like AccessKeyId
|
||||
const redactedValue = "****"
|
||||
|
||||
// sensitiveFieldNames lists header, query, and form field names (matched
|
||||
// case-insensitively) whose values are bearer credentials or raw key
|
||||
// material rather than diagnostic data: a JWT, a session token, a request
|
||||
// signature, or an SSE-C encryption key. Anyone with log access could
|
||||
// replay or reuse a logged value directly, so these are replaced with
|
||||
// redactedValue everywhere a request or response is logged, in both normal
|
||||
// and debug-mode logging.
|
||||
var sensitiveFieldNames = map[string]bool{
|
||||
"authorization": true,
|
||||
"x-amz-security-token": true,
|
||||
"webidentitytoken": true,
|
||||
// The request signature itself: with the rest of a presigned URL
|
||||
// (which is not otherwise secret) this is everything needed to replay
|
||||
// the exact request until it expires.
|
||||
"x-amz-signature": true,
|
||||
// Carries the access key ID. Not secret on its own, but there's no
|
||||
// diagnostic value in logging it that isn't already available from
|
||||
// the (also masked) Authorization header, so mask it defensively too.
|
||||
"x-amz-credential": true,
|
||||
// SSE-C requests carry the raw AES-256 customer-provided encryption
|
||||
// key in these headers. The paired "...-key-md5" headers are just a
|
||||
// checksum of the key (not reversible to the key itself), so they're
|
||||
// left unmasked to help correlate requests using the same key.
|
||||
"x-amz-server-side-encryption-customer-key": true,
|
||||
"x-amz-copy-source-server-side-encryption-customer-key": true,
|
||||
}
|
||||
|
||||
func isSensitiveFieldName(name string) bool {
|
||||
return sensitiveFieldNames[strings.ToLower(name)]
|
||||
}
|
||||
|
||||
// redact returns redactedValue in place of value when key names a
|
||||
// credential-bearing header, query, or form field.
|
||||
func redact(key, value string) string {
|
||||
if isSensitiveFieldName(key) {
|
||||
return redactedValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// RedactedQueryString rebuilds the request's query string with sensitive
|
||||
// parameter values (see sensitiveFieldNames) replaced by redactedValue. It
|
||||
// is safe to write to any log, including the default (non-debug) access
|
||||
// log.
|
||||
func RedactedQueryString(queryArgs *fasthttp.Args) string {
|
||||
if queryArgs.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for key, value := range queryArgs.All() {
|
||||
if !first {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
first = false
|
||||
b.WriteString(url.QueryEscape(string(key)))
|
||||
b.WriteByte('=')
|
||||
b.WriteString(url.QueryEscape(redact(string(key), string(value))))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RedactedQueryParamsTag is a logger.LogFunc that replaces the fiber logger
|
||||
// middleware's built-in ${queryParams} tag with a redacted query string
|
||||
// (see RedactedQueryString). Register it as a CustomTags override for
|
||||
// logger.TagQueryStringParams so the default (non-debug) access log never
|
||||
// writes credential-bearing query parameters such as WebIdentityToken or
|
||||
// X-Amz-Security-Token.
|
||||
var RedactedQueryParamsTag logger.LogFunc = func(output logger.Buffer, ctx fiber.Ctx, _ *logger.Data, _ string) (int, error) {
|
||||
return output.WriteString(RedactedQueryString(ctx.Request().URI().QueryArgs()))
|
||||
}
|
||||
|
||||
// debugRedact is redact's counterpart for the debug logger's own
|
||||
// header/query/form-field printing. Unlike redact (used by the always-on,
|
||||
// non-debug access log), it honors LevelUnsafe: at that level it returns
|
||||
// value unchanged so the debug output shows exactly what was on the wire.
|
||||
// At LevelDebug it masks identically to redact.
|
||||
func debugRedact(key, value string) string {
|
||||
if IsUnsafeEnabled() {
|
||||
return value
|
||||
}
|
||||
return redact(key, value)
|
||||
}
|
||||
|
||||
// debugRedactedQueryString is RedactedQueryString's counterpart for the
|
||||
// debug logger, using debugRedact so LevelUnsafe shows unmasked values.
|
||||
func debugRedactedQueryString(queryArgs *fasthttp.Args) string {
|
||||
if queryArgs.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for key, value := range queryArgs.All() {
|
||||
if !first {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
first = false
|
||||
b.WriteString(url.QueryEscape(string(key)))
|
||||
b.WriteByte('=')
|
||||
b.WriteString(url.QueryEscape(debugRedact(string(key), string(value))))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package debuglogger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func TestRedact(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{name: "Authorization header", key: "Authorization", value: "AWS4-HMAC-SHA256 ...", want: redactedValue},
|
||||
{name: "header name matched case-insensitively", key: "AUTHORIZATION", value: "secret", want: redactedValue},
|
||||
{name: "security token", key: "X-Amz-Security-Token", value: "secret", want: redactedValue},
|
||||
{name: "presigned request signature", key: "X-Amz-Signature", value: "deadbeef", want: redactedValue},
|
||||
{name: "presigned request signature matched case-insensitively", key: "x-amz-signature", value: "deadbeef", want: redactedValue},
|
||||
{name: "presigned request credential", key: "X-Amz-Credential", value: "AKIAEXAMPLE/20260101/us-east-1/s3/aws4_request", want: redactedValue},
|
||||
{name: "web identity token form/query field", key: "WebIdentityToken", value: "secret", want: redactedValue},
|
||||
{name: "SSE-C customer key header", key: "X-Amz-Server-Side-Encryption-Customer-Key", value: "base64key==", want: redactedValue},
|
||||
{name: "SSE-C copy-source customer key header", key: "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key", value: "base64key==", want: redactedValue},
|
||||
{name: "SSE-C customer key MD5 untouched (checksum, not a secret)", key: "X-Amz-Server-Side-Encryption-Customer-Key-MD5", value: "deadbeef==", want: "deadbeef=="},
|
||||
{name: "unrelated header untouched", key: "Content-Type", value: "application/xml", want: "application/xml"},
|
||||
{name: "unrelated query param untouched", key: "Action", value: "AssumeRoleWithWebIdentity", want: "AssumeRoleWithWebIdentity"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := redact(tt.key, tt.value); got != tt.want {
|
||||
t.Errorf("redact(%q, %q) = %q, want %q", tt.key, tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugRedactHonorsUnsafeLevel(t *testing.T) {
|
||||
defer SetLevel(LevelSilent)
|
||||
|
||||
SetLevel(LevelDebug)
|
||||
if got := debugRedact("Authorization", "secret-sig"); got != redactedValue {
|
||||
t.Errorf("debugRedact at LevelDebug = %q, want %q", got, redactedValue)
|
||||
}
|
||||
|
||||
SetLevel(LevelUnsafe)
|
||||
if got := debugRedact("Authorization", "secret-sig"); got != "secret-sig" {
|
||||
t.Errorf("debugRedact at LevelUnsafe = %q, want unmasked value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactedQueryString(t *testing.T) {
|
||||
args := &fasthttp.Args{}
|
||||
args.Parse("Action=AssumeRoleWithWebIdentity&WebIdentityToken=super-secret-jwt")
|
||||
|
||||
got := RedactedQueryString(args)
|
||||
|
||||
if strings.Contains(got, "super-secret-jwt") {
|
||||
t.Fatalf("RedactedQueryString leaked the token: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Action=AssumeRoleWithWebIdentity") {
|
||||
t.Errorf("RedactedQueryString dropped a non-sensitive param: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, url.QueryEscape(redactedValue)) {
|
||||
t.Errorf("RedactedQueryString missing redaction marker: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactedQueryStringEmpty(t *testing.T) {
|
||||
if got := RedactedQueryString(&fasthttp.Args{}); got != "" {
|
||||
t.Errorf("RedactedQueryString(empty) = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactedQueryStringMasksPresignedCredentials asserts that a presigned
|
||||
// request's X-Amz-Signature (and X-Amz-Credential) never reach the default
|
||||
// access log, since together with the rest of the (non-secret) presigned URL
|
||||
// they're everything needed to replay the exact signed request until it
|
||||
// expires.
|
||||
func TestRedactedQueryStringMasksPresignedCredentials(t *testing.T) {
|
||||
args := &fasthttp.Args{}
|
||||
args.Parse("X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAEXAMPLE%2F20260101%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=deadbeefcafe")
|
||||
|
||||
got := RedactedQueryString(args)
|
||||
|
||||
for _, secret := range []string{"deadbeefcafe", "AKIAEXAMPLE"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("RedactedQueryString leaked presigned credential material %q: %q", secret, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "X-Amz-Algorithm=AWS4-HMAC-SHA256") {
|
||||
t.Errorf("RedactedQueryString dropped a non-sensitive param: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogFiberRequestAndResponseDetailsRedactSensitiveFields sends dummy
|
||||
// secrets through the request header, query, and form-body paths (plus the
|
||||
// response header path) and asserts that none of them appear in the debug
|
||||
// logger's captured output, only the redaction marker in their place. This
|
||||
// covers a GET AssumeRoleWithWebIdentity's WebIdentityToken query parameter,
|
||||
// and, in debug mode, the Authorization and X-Amz-Security-Token headers.
|
||||
func TestLogFiberRequestAndResponseDetailsRedactSensitiveFields(t *testing.T) {
|
||||
const (
|
||||
dummyToken = "dummy-web-identity-jwt"
|
||||
dummyAuth = "AWS4-HMAC-SHA256 Credential=AKIADUMMYEXAMPLE/..."
|
||||
dummySecurity = "dummy-security-token"
|
||||
)
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/", func(ctx fiber.Ctx) error {
|
||||
LogFiberRequestDetails(ctx)
|
||||
ctx.Response().Header.Set("X-Amz-Security-Token", dummySecurity)
|
||||
LogFiberResponseDetails(ctx)
|
||||
return ctx.SendString("ok")
|
||||
})
|
||||
|
||||
body := "Action=AssumeRoleWithWebIdentity&WebIdentityToken=" + dummyToken
|
||||
req := httptest.NewRequest(http.MethodPost, "/?WebIdentityToken="+dummyToken, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", fiber.MIMEApplicationForm)
|
||||
req.Header.Set("Authorization", dummyAuth)
|
||||
req.Header.Set("X-Amz-Security-Token", dummySecurity)
|
||||
|
||||
output := captureLogOutput(t, func() {
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, secret := range []string{dummyToken, dummyAuth, dummySecurity} {
|
||||
if strings.Contains(output, secret) {
|
||||
t.Errorf("captured debug output leaked secret %q:\n%s", secret, output)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(output, redactedValue) {
|
||||
t.Errorf("expected redaction marker %q in captured output:\n%s", redactedValue, output)
|
||||
}
|
||||
}
|
||||
|
||||
// captureLogOutput redirects both fmt.Printf (via os.Stdout, used by the
|
||||
// box-drawing helpers) and the standard "log" package (used for the
|
||||
// per-query-arg lines) into a buffer for the duration of fn.
|
||||
func captureLogOutput(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
|
||||
origStdout := os.Stdout
|
||||
origLogOutput := log.Writer()
|
||||
os.Stdout = w
|
||||
log.SetOutput(w)
|
||||
defer func() {
|
||||
os.Stdout = origStdout
|
||||
log.SetOutput(origLogOutput)
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
w.Close()
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf("io.Copy: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package debuglogger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// accessKeyVisiblePrefixLen is the number of leading characters left
|
||||
// visible when partially masking an access key ID (e.g. "AKIA" or "ASIA"),
|
||||
// enough to identify the credential type without exposing the value.
|
||||
const accessKeyVisiblePrefixLen = 4
|
||||
|
||||
// fullyMaskedXMLElements lists XML element (and attribute) local names
|
||||
// whose text content is a usable credential. Every occurrence, at any
|
||||
// nesting depth, is replaced with redactedValue when masking applies.
|
||||
var fullyMaskedXMLElements = map[string]bool{
|
||||
"SecretAccessKey": true,
|
||||
"SessionToken": true,
|
||||
"WebIdentityToken": true,
|
||||
}
|
||||
|
||||
// partiallyMaskedXMLElements lists XML element (and attribute) local names
|
||||
// whose value is not itself a bearer credential but is still worth
|
||||
// partially hiding. Only a short identifying prefix is left visible; see
|
||||
// maskPartial.
|
||||
var partiallyMaskedXMLElements = map[string]bool{
|
||||
"AccessKeyId": true,
|
||||
}
|
||||
|
||||
// maskPartial reveals only the first accessKeyVisiblePrefixLen characters
|
||||
// of value, replacing the rest with redactedValue. Values no longer than
|
||||
// the visible prefix are masked in full, so short values are never fully
|
||||
// exposed.
|
||||
func maskPartial(value string) string {
|
||||
if len(value) <= accessKeyVisiblePrefixLen {
|
||||
return redactedValue
|
||||
}
|
||||
return value[:accessKeyVisiblePrefixLen] + redactedValue
|
||||
}
|
||||
|
||||
// maskXMLValue returns the masked form of an XML element or attribute
|
||||
// named name with text content value, per fullyMaskedXMLElements and
|
||||
// partiallyMaskedXMLElements. It returns value unchanged when name isn't
|
||||
// sensitive, or when unsafe is true (LevelUnsafe: print everything as-is).
|
||||
func maskXMLValue(name, value string, unsafe bool) string {
|
||||
if unsafe {
|
||||
return value
|
||||
}
|
||||
if fullyMaskedXMLElements[name] {
|
||||
return redactedValue
|
||||
}
|
||||
if partiallyMaskedXMLElements[name] {
|
||||
return maskPartial(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// xmlNode is an in-memory XML element tree, used so the pretty-printer can
|
||||
// decide per element whether to inline its text content or nest its
|
||||
// children, and can mask leaf text without disturbing surrounding
|
||||
// structure, namespaces, or attributes.
|
||||
type xmlNode struct {
|
||||
name string
|
||||
space string // namespace URI; only rendered at the root
|
||||
attrs []xml.Attr
|
||||
text string
|
||||
children []*xmlNode
|
||||
}
|
||||
|
||||
// maskXMLBody parses body as XML, and returns a pretty-printed copy with
|
||||
// sensitive element and attribute values masked (per maskXMLValue), and ok
|
||||
// true. If body is not well-formed XML, it returns (nil, false) and the
|
||||
// caller should fall back to printing the raw bytes.
|
||||
//
|
||||
// The parse-then-render round trip preserves the full document structure
|
||||
// (namespace, nesting, attributes) exactly, since every element still
|
||||
// carries its original name, namespace, attributes, and children; only leaf
|
||||
// text content matching a sensitive field name is replaced.
|
||||
func maskXMLBody(body []byte) ([]byte, bool) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 || trimmed[0] != '<' {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
root, xmlDecl, err := parseXMLTree(dec)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if xmlDecl != "" {
|
||||
out.WriteString(xmlDecl)
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
renderXMLNode(&out, root, 0, IsUnsafeEnabled())
|
||||
return out.Bytes(), true
|
||||
}
|
||||
|
||||
// parseXMLTree reads tokens from dec up to and including the document's
|
||||
// single root element, returning that element as a tree and the raw XML
|
||||
// declaration (e.g. `<?xml version="1.0" encoding="UTF-8"?>`) if present.
|
||||
func parseXMLTree(dec *xml.Decoder) (*xmlNode, string, error) {
|
||||
var xmlDecl string
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.ProcInst:
|
||||
if t.Target == "xml" {
|
||||
xmlDecl = fmt.Sprintf("<?xml %s?>", strings.TrimSpace(string(t.Inst)))
|
||||
}
|
||||
case xml.StartElement:
|
||||
root, err := parseXMLElement(dec, t)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return root, xmlDecl, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseXMLElement reads dec until the matching end element for start,
|
||||
// building the element subtree.
|
||||
func parseXMLElement(dec *xml.Decoder, start xml.StartElement) (*xmlNode, error) {
|
||||
n := &xmlNode{name: start.Name.Local, space: start.Name.Space}
|
||||
for _, a := range start.Attr {
|
||||
// xmlns / xmlns:* declarations are re-derived from Name.Space when
|
||||
// rendering the root element; keep only "real" attributes here.
|
||||
if a.Name.Space == "xmlns" || a.Name.Local == "xmlns" {
|
||||
continue
|
||||
}
|
||||
n.attrs = append(n.attrs, a)
|
||||
}
|
||||
|
||||
var text bytes.Buffer
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
child, err := parseXMLElement(dec, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.children = append(n.children, child)
|
||||
case xml.EndElement:
|
||||
n.text = text.String()
|
||||
return n, nil
|
||||
case xml.CharData:
|
||||
text.Write(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderXMLNode writes n to out at the given indent depth, masking leaf
|
||||
// text and attribute values per maskXMLValue.
|
||||
func renderXMLNode(out *bytes.Buffer, n *xmlNode, depth int, unsafe bool) {
|
||||
out.WriteString(strings.Repeat(" ", depth))
|
||||
out.WriteByte('<')
|
||||
out.WriteString(n.name)
|
||||
if depth == 0 && n.space != "" {
|
||||
fmt.Fprintf(out, ` xmlns="%s"`, escapeXML(n.space))
|
||||
}
|
||||
for _, a := range n.attrs {
|
||||
attrName := a.Name.Local
|
||||
if a.Name.Space != "" {
|
||||
attrName = a.Name.Space + ":" + attrName
|
||||
}
|
||||
fmt.Fprintf(out, ` %s="%s"`, attrName, escapeXML(maskXMLValue(a.Name.Local, a.Value, unsafe)))
|
||||
}
|
||||
|
||||
hasText := strings.TrimSpace(n.text) != ""
|
||||
if len(n.children) == 0 && !hasText {
|
||||
out.WriteString("></")
|
||||
out.WriteString(n.name)
|
||||
out.WriteString(">\n")
|
||||
return
|
||||
}
|
||||
|
||||
out.WriteByte('>')
|
||||
if len(n.children) > 0 {
|
||||
out.WriteByte('\n')
|
||||
for _, c := range n.children {
|
||||
renderXMLNode(out, c, depth+1, unsafe)
|
||||
}
|
||||
out.WriteString(strings.Repeat(" ", depth))
|
||||
} else {
|
||||
out.WriteString(escapeXML(maskXMLValue(n.name, n.text, unsafe)))
|
||||
}
|
||||
out.WriteString("</")
|
||||
out.WriteString(n.name)
|
||||
out.WriteString(">\n")
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
var buf bytes.Buffer
|
||||
// xml.EscapeText never returns an error for a bytes.Buffer destination.
|
||||
_ = xml.EscapeText(&buf, []byte(s))
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package debuglogger
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const stsBody = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/"><AssumeRoleWithWebIdentityResult><AssumedRoleUser><AssumedRoleId>AROAEXAMPLE:session</AssumedRoleId><Arn>arn:aws:sts::123456789012:assumed-role/role/session</Arn></AssumedRoleUser><Provider>https://idp.example.com</Provider><Credentials><AccessKeyId>ASIAabcdefghijklmnop</AccessKeyId><SecretAccessKey>supersecretvalue1234567890</SecretAccessKey><SessionToken>tokentokentokentoken</SessionToken><Expiration>2026-07-30T12:00:00Z</Expiration></Credentials><SubjectFromWebIdentityToken>subject-123</SubjectFromWebIdentityToken></AssumeRoleWithWebIdentityResult><ResponseMetadata><RequestId>req-123</RequestId></ResponseMetadata></AssumeRoleWithWebIdentityResponse>`
|
||||
|
||||
func TestMaskXMLBodyMasksSecretsAtDebugLevel(t *testing.T) {
|
||||
SetLevel(LevelDebug)
|
||||
defer SetLevel(LevelSilent)
|
||||
|
||||
out, ok := maskXMLBody([]byte(stsBody))
|
||||
if !ok {
|
||||
t.Fatalf("maskXMLBody: expected ok=true for well-formed XML")
|
||||
}
|
||||
got := string(out)
|
||||
|
||||
for _, secret := range []string{"supersecretvalue1234567890", "tokentokentokentoken"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Errorf("masked output leaked secret %q:\n%s", secret, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "<SecretAccessKey>****</SecretAccessKey>") {
|
||||
t.Errorf("expected SecretAccessKey to be fully masked:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<SessionToken>****</SessionToken>") {
|
||||
t.Errorf("expected SessionToken to be fully masked:\n%s", got)
|
||||
}
|
||||
// AccessKeyId is partially masked: first 4 chars visible.
|
||||
if !strings.Contains(got, "<AccessKeyId>ASIA****</AccessKeyId>") {
|
||||
t.Errorf("expected AccessKeyId to be partially masked with prefix visible:\n%s", got)
|
||||
}
|
||||
// Non-sensitive fields must survive untouched.
|
||||
for _, want := range []string{
|
||||
`xmlns="https://sts.amazonaws.com/doc/2011-06-15/"`,
|
||||
"<AssumedRoleId>AROAEXAMPLE:session</AssumedRoleId>",
|
||||
"<Arn>arn:aws:sts::123456789012:assumed-role/role/session</Arn>",
|
||||
"<Provider>https://idp.example.com</Provider>",
|
||||
"<Expiration>2026-07-30T12:00:00Z</Expiration>",
|
||||
"<RequestId>req-123</RequestId>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("expected masked output to preserve %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// The namespace must be declared exactly once (on the root), not
|
||||
// redeclared on every nested element.
|
||||
if n := strings.Count(got, "xmlns="); n != 1 {
|
||||
t.Errorf("expected exactly one xmlns declaration, got %d:\n%s", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskXMLBodyUnsafeLevelShowsSecrets(t *testing.T) {
|
||||
SetLevel(LevelUnsafe)
|
||||
defer SetLevel(LevelSilent)
|
||||
|
||||
out, ok := maskXMLBody([]byte(stsBody))
|
||||
if !ok {
|
||||
t.Fatalf("maskXMLBody: expected ok=true for well-formed XML")
|
||||
}
|
||||
got := string(out)
|
||||
|
||||
for _, secret := range []string{"supersecretvalue1234567890", "tokentokentokentoken", "ASIAabcdefghijklmnop"} {
|
||||
if !strings.Contains(got, secret) {
|
||||
t.Errorf("unsafe-level output should show secret %q in the clear:\n%s", secret, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskXMLBodyPreservesNestingAndAttributes(t *testing.T) {
|
||||
SetLevel(LevelDebug)
|
||||
defer SetLevel(LevelSilent)
|
||||
|
||||
body := `<Root xmlns="urn:example"><Outer id="1"><Inner>value</Inner><Inner>value2</Inner></Outer></Root>`
|
||||
out, ok := maskXMLBody([]byte(body))
|
||||
if !ok {
|
||||
t.Fatalf("maskXMLBody: expected ok=true")
|
||||
}
|
||||
got := string(out)
|
||||
|
||||
if strings.Count(got, "<Inner>") != 2 {
|
||||
t.Errorf("expected both nested Inner elements to survive:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `id="1"`) {
|
||||
t.Errorf("expected attribute to survive:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskXMLBodyRejectsMalformedOrNonXML(t *testing.T) {
|
||||
SetLevel(LevelDebug)
|
||||
defer SetLevel(LevelSilent)
|
||||
|
||||
for _, body := range []string{
|
||||
"",
|
||||
" ",
|
||||
"<Unclosed>",
|
||||
`{"json":"body"}`,
|
||||
"plain text body",
|
||||
} {
|
||||
if _, ok := maskXMLBody([]byte(body)); ok {
|
||||
t.Errorf("maskXMLBody(%q): expected ok=false", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskPartial(t *testing.T) {
|
||||
tests := []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"AKIAabcdefghijklmnop", "AKIA****"},
|
||||
{"ASIA", "****"},
|
||||
{"abc", "****"},
|
||||
{"", "****"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := maskPartial(tt.value); got != tt.want {
|
||||
t.Errorf("maskPartial(%q) = %q, want %q", tt.value, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user