Skip to content
Draft
30 changes: 29 additions & 1 deletion .wick.yaml.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Wick project configuration
# Place this file as .wick.yaml in your project root.

# Redaction style: "redacted" (default), "stars", or a custom string.
# Redaction style: "redacted" (default), "stars", "hash", or a custom string.
# style: redacted

# Custom detection patterns (in addition to built-in secrets and PII).
Expand All @@ -11,3 +11,31 @@
# - name: internal-hostname
# regex: "\\w+\\.internal\\.acme\\.com"
# replacement: "[INTERNAL-HOST]"

# Allowlist: known-safe values that should never be redacted.
# Use exact strings or set regex: true for regular expressions.
# allowlist:
# - pattern: "AKIAIOSFODNN7EXAMPLE"
# reason: "AWS documentation example key"
# - pattern: "test@example\\.com"
# regex: true
# reason: "Test fixture email"
# - pattern: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
# reason: "AWS documentation example secret"

# Blocklist: patterns that are always redacted, even if not in built-in rules.
# blocklist:
# - pattern: "ACME-INTERNAL-[A-Z0-9]+"
# category: "custom"
# reason: "Internal project codes"
# - pattern: "\\w+\\.corp\\.acme\\.com"
# category: "custom"
# reason: "Internal hostnames"

# Load additional Gitleaks-compatible rules from a TOML file.
# rules_file: "./my-rules.toml"

# Disable specific built-in rules by ID (use wick --report to see rule IDs).
# disable_rules:
# - "generic-api-key"
# - "email"
101 changes: 101 additions & 0 deletions allowlist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package wick

import (
"strings"
"testing"

"github.com/krypsis-io/wick/detect"
)

func TestWithAllowlist_SuppressesValue(t *testing.T) {
input := "Contact admin@acme.com for help"
output, report, err := Redact(input, WithAllowlist([]detect.AllowlistEntry{
{Pattern: "admin@acme.com", Reason: "test fixture"},
}))
if err != nil {
t.Fatalf("Redact: %v", err)
}
if strings.Contains(output, "[REDACTED]") {
t.Errorf("allowlisted value should not be redacted: %s", output)
}
if output != input {
t.Errorf("output should equal input when all findings are allowlisted: %s", output)
}
if report.Total != 0 {
t.Errorf("expected 0 findings, got %d", report.Total)
}
}

func TestWithAllowlist_Regex(t *testing.T) {
input := "Email: test@fixture.com"
output, _, err := Redact(input, WithAllowlist([]detect.AllowlistEntry{
{Pattern: `test@.*\.com`, Regex: true},
}))
if err != nil {
t.Fatalf("Redact: %v", err)
}
if strings.Contains(output, "[REDACTED]") {
t.Errorf("regex-allowlisted value should not be redacted: %s", output)
}
}

func TestWithAllowlist_OnlyAllowlistedSuppressed(t *testing.T) {
input := "safe@example.com and danger@corp.com both here"
_, report, err := Redact(input, WithAllowlist([]detect.AllowlistEntry{
{Pattern: "safe@example.com"},
}))
if err != nil {
t.Fatalf("Redact: %v", err)
}
for _, f := range report.Findings {
if f.Value == "safe@example.com" {
t.Errorf("safe@example.com should be suppressed by allowlist")
}
}
found := false
for _, f := range report.Findings {
if f.Value == "danger@corp.com" {
found = true
}
}
if !found {
t.Error("danger@corp.com should still be detected")
}
}

func TestWithBlocklist_AlwaysRedacts(t *testing.T) {
// Custom value not in any built-in rule.
input := "Project code: ACME-INTERNAL-ABC123"
output, report, err := Redact(input, WithBlocklist([]detect.CustomPattern{
{Name: "internal-code", Regex: `ACME-INTERNAL-[A-Z0-9]+`},
}))
if err != nil {
t.Fatalf("Redact: %v", err)
}
if strings.Contains(output, "ACME-INTERNAL-ABC123") {
t.Errorf("blocklisted value should be redacted: %s", output)
}
if report.Total == 0 {
t.Error("expected at least 1 finding from blocklist")
}
}

func TestWithBlocklist_CombinedWithBuiltins(t *testing.T) {
// Blocklist adds to built-in detection, not replaces it.
input := "Email: admin@acme.com, Code: ACME-INTERNAL-XYZ"
output, report, err := Redact(input, WithBlocklist([]detect.CustomPattern{
{Name: "internal-code", Regex: `ACME-INTERNAL-[A-Z]+`},
}))
if err != nil {
t.Fatalf("Redact: %v", err)
}
if strings.Contains(output, "admin@acme.com") {
t.Errorf("email should still be detected: %s", output)
}
if strings.Contains(output, "ACME-INTERNAL-XYZ") {
t.Errorf("blocklisted value should be redacted: %s", output)
}
if report.Total < 2 {
t.Errorf("expected at least 2 findings, got %d", report.Total)
}
}
195 changes: 195 additions & 0 deletions dehydrate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package wick

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestGenerateKey(t *testing.T) {
k1, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
k2, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if k1 == k2 {
t.Error("two GenerateKey calls should not produce the same key")
}
}

func TestDecodeKey_Valid(t *testing.T) {
encoded, _ := GenerateKey()
key, err := DecodeKey(encoded)
if err != nil {
t.Fatalf("DecodeKey: %v", err)
}
if len(key) != 32 {
t.Errorf("expected 32-byte key, got %d", len(key))
}
}

func TestDecodeKey_Invalid(t *testing.T) {
if _, err := DecodeKey("not-base64!!!"); err == nil {
t.Error("expected error for invalid base64")
}
// Valid base64 but wrong length (16 bytes = 24 base64 chars).
if _, err := DecodeKey("AAAAAAAAAAAAAAAAAAAAAA=="); err == nil {
t.Error("expected error for wrong key length")
}
}

func TestDehydrate_Basic(t *testing.T) {
input := "Contact admin@acme.com from 10.0.1.42"
key, _ := GenerateKey()
keyBytes, _ := DecodeKey(key)

redacted, tm, err := Dehydrate(input, keyBytes)
if err != nil {
t.Fatalf("Dehydrate: %v", err)
}

if strings.Contains(redacted, "admin@acme.com") {
t.Errorf("email should be redacted: %s", redacted)
}
if strings.Contains(redacted, "10.0.1.42") {
t.Errorf("IP should be redacted: %s", redacted)
}
if len(tm.entries) == 0 {
t.Error("expected non-empty token map")
}
}

func TestRoundTrip(t *testing.T) {
input := "Contact admin@acme.com from 10.0.1.42 — key: AKIAZ5GMHYJKLMNOPQRS"
key, _ := GenerateKey()
keyBytes, _ := DecodeKey(key)

redacted, tm, err := Dehydrate(input, keyBytes)
if err != nil {
t.Fatalf("Dehydrate: %v", err)
}

restored, err := Rehydrate(redacted, tm)
if err != nil {
t.Fatalf("Rehydrate: %v", err)
}

if restored != input {
t.Errorf("round-trip failed:\n input: %q\n restored: %q", input, restored)
}
}

func TestRoundTrip_RepeatedValue(t *testing.T) {
input := "admin@acme.com is the admin. Contact admin@acme.com."
key, _ := GenerateKey()
keyBytes, _ := DecodeKey(key)

redacted, tm, err := Dehydrate(input, keyBytes)
if err != nil {
t.Fatalf("Dehydrate: %v", err)
}

restored, err := Rehydrate(redacted, tm)
if err != nil {
t.Fatalf("Rehydrate: %v", err)
}

if restored != input {
t.Errorf("round-trip with repeated value failed:\n input: %q\n restored: %q", input, restored)
}
}

func TestSaveAndLoadTokenMap(t *testing.T) {
input := "Contact admin@acme.com from 10.0.1.42"
key, _ := GenerateKey()
keyBytes, _ := DecodeKey(key)

_, tm, err := Dehydrate(input, keyBytes)
if err != nil {
t.Fatalf("Dehydrate: %v", err)
}

tmpFile := filepath.Join(t.TempDir(), "tokens.enc")
if err := SaveTokenMap(tm, keyBytes, tmpFile); err != nil {
t.Fatalf("SaveTokenMap: %v", err)
}

loaded, err := LoadTokenMap(keyBytes, tmpFile)
if err != nil {
t.Fatalf("LoadTokenMap: %v", err)
}

if len(loaded.entries) != len(tm.entries) {
t.Errorf("loaded %d entries, want %d", len(loaded.entries), len(tm.entries))
}
}

func TestLoadTokenMap_WrongKey(t *testing.T) {
input := "admin@acme.com"
key, _ := GenerateKey()
keyBytes, _ := DecodeKey(key)

_, tm, _ := Dehydrate(input, keyBytes)
tmpFile := filepath.Join(t.TempDir(), "tokens.enc")
_ = SaveTokenMap(tm, keyBytes, tmpFile)

wrongKey, _ := GenerateKey()
wrongKeyBytes, _ := DecodeKey(wrongKey)
if _, err := LoadTokenMap(wrongKeyBytes, tmpFile); err == nil {
t.Error("expected error when decrypting with wrong key")
}
}

func TestLoadTokenMap_MissingFile(t *testing.T) {
key, _ := GenerateKey()
keyBytes, _ := DecodeKey(key)
if _, err := LoadTokenMap(keyBytes, "/nonexistent/path.enc"); err == nil {
t.Error("expected error for missing file")
}
}

func TestSaveTokenMap_FilePermissions(t *testing.T) {
input := "admin@acme.com"
key, _ := GenerateKey()
keyBytes, _ := DecodeKey(key)
_, tm, _ := Dehydrate(input, keyBytes)

tmpFile := filepath.Join(t.TempDir(), "tokens.enc")
if err := SaveTokenMap(tm, keyBytes, tmpFile); err != nil {
t.Fatalf("SaveTokenMap: %v", err)
}

info, err := os.Stat(tmpFile)
if err != nil {
t.Fatalf("stat: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("expected file mode 0600, got %o", info.Mode().Perm())
}
}

func TestRehydrate_NoFindings(t *testing.T) {
input := "nothing sensitive here"
key, _ := GenerateKey()
keyBytes, _ := DecodeKey(key)

redacted, tm, err := Dehydrate(input, keyBytes)
if err != nil {
t.Fatalf("Dehydrate: %v", err)
}
if redacted != input {
t.Errorf("no-findings input should pass through unchanged: %q", redacted)
}

restored, err := Rehydrate(redacted, tm)
if err != nil {
t.Fatalf("Rehydrate: %v", err)
}
if restored != input {
t.Errorf("round-trip with no findings failed: %q", restored)
}
}
Loading
Loading