Files
2026-07-13 13:00:08 +08:00

220 lines
7.7 KiB
Go

package secrets
import (
"strings"
"testing"
"reasonix/internal/provider"
)
func TestRedactMasksCommonSecretShapes(t *testing.T) {
in := strings.Join([]string{
"DEEPSEEK_API_KEY=sk-real-secret-value-123456",
"Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz",
"token xoxb-123456789012-abcdefabcdef",
"jwt eyJabc.def.ghi",
}, "\n")
got := Redact(in)
for _, leaked := range []string{
"sk-real-secret-value-123456",
"ghp_abcdefghijklmnopqrstuvwxyz",
"xoxb-123456789012-abcdefabcdef",
"eyJabc.def.ghi",
} {
if strings.Contains(got, leaked) {
t.Fatalf("secret leaked %q in:\n%s", leaked, got)
}
}
for _, want := range []string{"DEEPSEEK_API_KEY=sk-rea", "Authorization: Bearer [redacted]"} {
if !strings.Contains(got, want) {
t.Fatalf("redacted output missing %q:\n%s", want, got)
}
}
}
func TestRedactMasksJSONQuotedKeys(t *testing.T) {
in := `http 401: {"access_token":"sk-live-secret","x-api-key":"header-secret","password":"pw-secret"}`
got := Redact(in)
for _, leaked := range []string{"sk-live-secret", "header-secret", "pw-secret"} {
if strings.Contains(got, leaked) {
t.Fatalf("JSON credential leaked %q in:\n%s", leaked, got)
}
}
if !strings.Contains(got, `"access_token":"`) || !strings.Contains(got, "http 401") {
t.Fatalf("non-secret structure mangled:\n%s", got)
}
if again := Redact(got); again != got {
t.Fatalf("JSON redaction not idempotent:\nonce: %q\ntwice: %q", got, again)
}
}
func TestRedactMasksCookieHeaderValues(t *testing.T) {
in := "Cookie: session=cookie-secret\nSet-Cookie: sid=abc123def456; Path=/; HttpOnly"
got := Redact(in)
for _, leaked := range []string{"cookie-secret", "abc123def456"} {
if strings.Contains(got, leaked) {
t.Fatalf("cookie value leaked %q in:\n%s", leaked, got)
}
}
for _, want := range []string{"Cookie: session=[redacted]", "Set-Cookie: sid=[redacted]", "HttpOnly"} {
if !strings.Contains(got, want) {
t.Fatalf("redacted output missing %q:\n%s", want, got)
}
}
if again := Redact(got); again != got {
t.Fatalf("cookie redaction not idempotent:\nonce: %q\ntwice: %q", got, again)
}
}
func TestRedactMasksNonBearerAuthorizationSchemes(t *testing.T) {
in := strings.Join([]string{
"Authorization: Basic dXNlcjpwYXNzd29yZA==",
"Proxy-Authorization: Digest username-hash-abcdef0123456789",
"Authorization: dXNlcjpwYXNzd29yZC1yYXc=",
}, "\n")
got := Redact(in)
for _, leaked := range []string{"dXNlcjpwYXNzd29yZA==", "username-hash-abcdef0123456789", "dXNlcjpwYXNzd29yZC1yYXc="} {
if strings.Contains(got, leaked) {
t.Fatalf("authorization credential leaked %q:\n%s", leaked, got)
}
}
for _, want := range []string{"Authorization: Basic [redacted]", "Digest [redacted]", "Authorization: [redacted]"} {
if !strings.Contains(got, want) {
t.Fatalf("redacted output missing %q:\n%s", want, got)
}
}
if again := Redact(got); again != got {
t.Fatalf("authorization redaction not idempotent:\nonce: %q\ntwice: %q", got, again)
}
}
func TestRedactIsIdempotent(t *testing.T) {
// The session save path re-redacts loaded (already-redacted) transcripts;
// digest stability across load/save cycles requires a byte-for-byte no-op.
in := strings.Join([]string{
"DEEPSEEK_API_KEY=sk-real-secret-value-123456",
"Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz",
"DB_PWD='hunter2-swordfish'",
"plain text with PWD=/home/user/project untouched",
}, "\n")
once := Redact(in)
twice := Redact(once)
if once != twice {
t.Fatalf("Redact not idempotent:\nonce: %q\ntwice: %q", once, twice)
}
}
func TestRedactLeavesWorkingDirectoryPWDAlone(t *testing.T) {
in := "PWD=/home/user/project\nOLDPWD=/home/user\nDB_PWD=hunter2-swordfish-123"
got := Redact(in)
if !strings.Contains(got, "PWD=/home/user/project") {
t.Fatalf("POSIX PWD variable was mangled:\n%s", got)
}
if !strings.Contains(got, "OLDPWD=/home/user") {
t.Fatalf("OLDPWD was mangled:\n%s", got)
}
if strings.Contains(got, "hunter2-swordfish-123") {
t.Fatalf("DB_PWD value leaked:\n%s", got)
}
}
func TestEnvKeySensitive(t *testing.T) {
sensitive := []string{"DEEPSEEK_API_KEY", "GH_TOKEN", "AWS_SECRET_ACCESS_KEY", "DB_PASSWORD", "MYSQL_PWD", "NPM_TOKEN"}
for _, key := range sensitive {
if !EnvKeySensitive(key) {
t.Errorf("EnvKeySensitive(%q) = false, want true", key)
}
}
benign := []string{"PWD", "OLDPWD", "PATH", "HOME", "LANG", "GOPATH", "TERM"}
for _, key := range benign {
if EnvKeySensitive(key) {
t.Errorf("EnvKeySensitive(%q) = true, want false", key)
}
}
}
func TestFilterEnvDropsSensitiveKeys(t *testing.T) {
got := FilterEnv([]string{
"PATH=/usr/bin",
"DEEPSEEK_API_KEY=sk-real-secret-value-123456",
"GH_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz",
"PWD=/home/user/project",
"HOME=/tmp/home",
})
joined := strings.Join(got, "\n")
if strings.Contains(joined, "DEEPSEEK_API_KEY") || strings.Contains(joined, "GH_TOKEN") {
t.Fatalf("sensitive env survived:\n%s", joined)
}
for _, want := range []string{"PATH=/usr/bin", "HOME=/tmp/home", "PWD=/home/user/project"} {
if !strings.Contains(joined, want) {
t.Fatalf("non-sensitive env %q dropped:\n%s", want, joined)
}
}
}
func TestProcessEnvUnfilteredByDefault(t *testing.T) {
t.Setenv("REASONIX_TEST_SECRET_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz")
joined := strings.Join(ProcessEnv(), "\n")
if !strings.Contains(joined, "REASONIX_TEST_SECRET_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz") {
t.Fatalf("ProcessEnv filtered by default; filter_subprocess_env must be opt-in:\n%s", joined)
}
SetFilterSubprocessEnv(true)
t.Cleanup(func() { SetFilterSubprocessEnv(false) })
joined = strings.Join(ProcessEnv(), "\n")
if strings.Contains(joined, "REASONIX_TEST_SECRET_TOKEN") {
t.Fatalf("ProcessEnv leaked sensitive key with filtering enabled:\n%s", joined)
}
}
func TestRedactToolOutputHonorsToggle(t *testing.T) {
const in = "DEEPSEEK_API_KEY=sk-real-secret-value-123456"
if got := RedactToolOutput(in); strings.Contains(got, "sk-real-secret-value-123456") {
t.Fatalf("tool output not redacted by default:\n%s", got)
}
SetRedactToolOutput(false)
t.Cleanup(func() { SetRedactToolOutput(true) })
if got := RedactToolOutput(in); got != in {
t.Fatalf("RedactToolOutput altered output with the toggle off:\n%s", got)
}
// The durable-surface entry point ignores the toggle.
if got := Redact(in); strings.Contains(got, "sk-real-secret-value-123456") {
t.Fatalf("Redact must stay active regardless of the toggle:\n%s", got)
}
}
func TestRedactMessagesDoesNotMutateInput(t *testing.T) {
const secret = "sk-real-secret-value-123456"
msgs := []provider.Message{
{
Role: provider.RoleAssistant,
Content: "checking",
ToolCalls: []provider.ToolCall{
{ID: "call_1", Name: "bash", Arguments: `{"command":"echo DEEPSEEK_API_KEY=` + secret + `"}`},
},
MemoryCitations: []provider.MemoryCitation{{Note: "token " + secret}},
},
{Role: provider.RoleTool, ToolCallID: "call_1", Content: "DEEPSEEK_API_KEY=" + secret},
}
out := RedactMessages(msgs)
// The redacted copy must not carry the raw secret...
if strings.Contains(out[0].ToolCalls[0].Arguments, secret) || strings.Contains(out[1].Content, secret) {
t.Fatalf("redacted copy leaked secret: %+v", out)
}
// ...and the input — live session history the model still replays — must
// be untouched, including through the shared ToolCalls/MemoryCitations
// backing arrays.
if !strings.Contains(msgs[0].ToolCalls[0].Arguments, secret) {
t.Fatalf("RedactMessages mutated the caller's ToolCalls: %q", msgs[0].ToolCalls[0].Arguments)
}
if !strings.Contains(msgs[0].MemoryCitations[0].Note, secret) {
t.Fatalf("RedactMessages mutated the caller's MemoryCitations: %q", msgs[0].MemoryCitations[0].Note)
}
if !strings.Contains(msgs[1].Content, secret) {
t.Fatalf("RedactMessages mutated the caller's Content: %q", msgs[1].Content)
}
}