Compare commits
2 Commits
f8cf27e5de
...
bad0581623
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad0581623 | ||
|
|
a94b860c2e |
@@ -256,6 +256,19 @@ func main() {
|
|||||||
logger.Error("CLAUDE_SESSIONS_DIR set but BRAIN_PG_DSN missing — claudewatcher needs the cursor table")
|
logger.Error("CLAUDE_SESSIONS_DIR set but BRAIN_PG_DSN missing — claudewatcher needs the cursor table")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
// Client-name guard. The env value is a regex alternation
|
||||||
|
// (e.g. "SEB|Mastercard"); we wrap it with word boundaries
|
||||||
|
// and case-insensitive flag so substrings inside longer
|
||||||
|
// identifiers don't false-match. Sourced from a SOPS secret
|
||||||
|
// so client identities never live in source.
|
||||||
|
if clientBlock := os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK"); clientBlock != "" {
|
||||||
|
pattern := `(?i)\b(` + clientBlock + `)\b`
|
||||||
|
if err := claudewatcher.RegisterRule("client-name", pattern); err != nil {
|
||||||
|
logger.Error("claudewatcher client-block rule invalid", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logger.Info("claudewatcher client-block guard registered")
|
||||||
|
}
|
||||||
cursorStore, cerr := claudewatcher.NewCursorStore(ctx, pgDSN)
|
cursorStore, cerr := claudewatcher.NewCursorStore(ctx, pgDSN)
|
||||||
if cerr != nil {
|
if cerr != nil {
|
||||||
logger.Error("claudewatcher cursor init", "err", cerr)
|
logger.Error("claudewatcher cursor init", "err", cerr)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package claudewatcher
|
package claudewatcher
|
||||||
|
|
||||||
import "regexp"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
// Scrubber drops any turn whose content matches a known-bad pattern.
|
// Scrubber drops any turn whose content matches a known-bad pattern.
|
||||||
// Fail-closed by design: we'd rather lose signal than ingest credentials
|
// Fail-closed by design: we'd rather lose signal than ingest credentials
|
||||||
@@ -45,10 +49,51 @@ var DefaultRules = []Rule{
|
|||||||
{Name: "sops-encrypted-marker", RE: regexp.MustCompile(`ENC\[AES256_GCM,data:[A-Za-z0-9+/=]{8,}`)},
|
{Name: "sops-encrypted-marker", RE: regexp.MustCompile(`ENC\[AES256_GCM,data:[A-Za-z0-9+/=]{8,}`)},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extraRules is appended to DefaultRules at process startup via
|
||||||
|
// RegisterRule. The mutex guards concurrent RegisterRule calls (rare)
|
||||||
|
// against concurrent Scrub reads (hot path). Scrub takes a read lock
|
||||||
|
// only when extraRules is non-empty, so steady-state cost is zero
|
||||||
|
// when no client-name guard is configured.
|
||||||
|
var (
|
||||||
|
extraRulesMu sync.RWMutex
|
||||||
|
extraRules []Rule
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRule appends a runtime-configured regex to the scrubber's
|
||||||
|
// rule set. Used by main to inject client-name guards from
|
||||||
|
// CLAUDE_INGEST_CLIENT_BLOCK env var (or equivalent SOPS-encrypted
|
||||||
|
// secret) without baking client identities into source code.
|
||||||
|
//
|
||||||
|
// pattern is compiled as-is — callers wrap with `\b...\b` and case
|
||||||
|
// flags as needed. Duplicate names are accepted (rules are positional);
|
||||||
|
// the second registration just fires after the first.
|
||||||
|
func RegisterRule(name, pattern string) error {
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compile rule %q: %w", name, err)
|
||||||
|
}
|
||||||
|
extraRulesMu.Lock()
|
||||||
|
extraRules = append(extraRules, Rule{Name: name, RE: re})
|
||||||
|
extraRulesMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetExtraRules clears every RegisterRule-added rule. Test-only.
|
||||||
|
func ResetExtraRules() {
|
||||||
|
extraRulesMu.Lock()
|
||||||
|
extraRules = nil
|
||||||
|
extraRulesMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// Scrub reports the first matching rule, or empty when content is clean.
|
// Scrub reports the first matching rule, or empty when content is clean.
|
||||||
// Empty string is treated as clean. Caller decides what to do on a hit;
|
// Empty string is treated as clean. Caller decides what to do on a hit;
|
||||||
// the convention in claudewatcher is to drop the turn entirely and emit
|
// the convention in claudewatcher is to drop the turn entirely and emit
|
||||||
// a slog.Warn naming the rule.
|
// a slog.Warn naming the rule.
|
||||||
|
//
|
||||||
|
// Rule order: DefaultRules first (credential shapes), then runtime
|
||||||
|
// RegisterRule additions (client-name guards). Credential leaks
|
||||||
|
// outrank client-name hits in the log because they're strictly more
|
||||||
|
// dangerous.
|
||||||
func Scrub(content string) string {
|
func Scrub(content string) string {
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -58,5 +103,12 @@ func Scrub(content string) string {
|
|||||||
return r.Name
|
return r.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
extraRulesMu.RLock()
|
||||||
|
defer extraRulesMu.RUnlock()
|
||||||
|
for _, r := range extraRules {
|
||||||
|
if r.RE.MatchString(content) {
|
||||||
|
return r.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,3 +55,63 @@ func TestScrub_FirstMatchWins(t *testing.T) {
|
|||||||
content := "Authorization: Bearer ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt"
|
content := "Authorization: Bearer ghp_aBcD1234EfGh5678IjKl9012MnOp3456QrSt"
|
||||||
assert.Equal(t, "authorization-header", Scrub(content))
|
assert.Equal(t, "authorization-header", Scrub(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterRule_ClientNameGuard(t *testing.T) {
|
||||||
|
t.Cleanup(ResetExtraRules)
|
||||||
|
require := func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(RegisterRule("client-name", `(?i)\b(SEB|Mastercard)\b`))
|
||||||
|
|
||||||
|
// Hits — case variations + word-boundary respect.
|
||||||
|
for _, hit := range []string{
|
||||||
|
"mentioned SEB in this commit",
|
||||||
|
"the Mastercard project deadline",
|
||||||
|
"working on mastercard scope",
|
||||||
|
"SEB internal review",
|
||||||
|
} {
|
||||||
|
assert.Equal(t, "client-name", Scrub(hit), "should match %q", hit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misses — substring within a longer word should NOT match
|
||||||
|
// thanks to \b. "Sebastian" contains "seb" but \b prevents hit.
|
||||||
|
for _, miss := range []string{
|
||||||
|
"Sebastian wrote the docs",
|
||||||
|
"unrelated text",
|
||||||
|
"researcher",
|
||||||
|
"https://example.com/search?seb=1", // 'seb' bounded by ?=, still matches \b
|
||||||
|
} {
|
||||||
|
got := Scrub(miss)
|
||||||
|
if miss == "https://example.com/search?seb=1" {
|
||||||
|
// `seb=` has word-boundary at '='; this DOES match \bseb\b.
|
||||||
|
// Accept either outcome; document the tradeoff.
|
||||||
|
assert.Contains(t, []string{"", "client-name"}, got)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Empty(t, got, "should NOT match %q", miss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRule_CredentialsTakePrecedence(t *testing.T) {
|
||||||
|
t.Cleanup(ResetExtraRules)
|
||||||
|
require := func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(RegisterRule("client-name", `\b(SEB)\b`))
|
||||||
|
|
||||||
|
// Content matches both a credential rule AND a client rule —
|
||||||
|
// credential rule wins by ordering, so log triage points at the
|
||||||
|
// strictly more dangerous leak.
|
||||||
|
content := "SEB project uses OPENAI_API_KEY=sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII"
|
||||||
|
assert.Equal(t, "openai-sk", Scrub(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRule_RejectsInvalidPattern(t *testing.T) {
|
||||||
|
t.Cleanup(ResetExtraRules)
|
||||||
|
err := RegisterRule("bad", "[unclosed")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user