package main import ( "encoding/json" "fmt" "os/exec" "runtime" "strings" "sync/atomic" "testing" "time" ) // Every value below is invented. Each case is a way a real config carries a // credential on the command line; the record that leaves the machine must // carry none of them, in any field. func TestMCPRecordNeverCarriesASecretValue(t *testing.T) { cases := []struct { name string e mcpEntry secrets []string }{ {"a database URL with a password", mcpEntry{command: "npx", args: []string{"-y", "@modelcontextprotocol/server-postgres", "postgresql://demo:PwDemo7731@localhost/demo"}}, []string{"PwDemo7731"}}, {"a key in a URL query, and an Authorization header argument", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://mcp.example.com/sse?api_key=QkeyDemo1234", "--header", "Authorization: Bearer HdrDemo45678"}}, []string{"QkeyDemo1234", "HdrDemo45678"}}, {"a header given as --header=Name:value", mcpEntry{command: "npx", args: []string{"mcp-remote", "--header=X-Api-Key:HkeyDemo9999"}}, []string{"HkeyDemo9999"}}, {"a short password after a secret flag", mcpEntry{command: "demo-srv", args: []string{"--password", "shrt1"}}, []string{"shrt1"}}, {"a password that starts with a dollar sign", mcpEntry{command: "demo-srv", args: []string{"--password", "$DollarPw99x"}}, []string{"DollarPw99x"}}, {"a short value in docker -e NAME=value", mcpEntry{command: "docker", args: []string{"run", "-e", "API_KEY=ab12", "demo/img"}}, []string{"ab12"}}, {"NAME=value inside --env=", mcpEntry{command: "docker", args: []string{"run", "--env=API_TOKEN=TokDemo5678", "demo/img"}}, []string{"TokDemo5678"}}, {"the whole command line in command", mcpEntry{command: "demo-srv --token CmdTokDemo42"}, []string{"CmdTokDemo42"}}, {"a git URL with a password", mcpEntry{command: "uvx", args: []string{"--from", "git+https://demo:GitPwDemo31@git.example.com/o/r", "demo"}}, []string{"GitPwDemo31"}}, {"a git URL with a token as the user", mcpEntry{command: "npx", args: []string{"-y", "git+https://GitUserTok55@git.example.com/o/r"}}, []string{"GitUserTok55"}}, {"a token in a URL path", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://hooks.example.com/api/s/PathTokDemo8812/sse"}}, []string{"PathTokDemo8812"}}, } for _, c := range cases { b, _ := json.Marshal(mcpAnalyze(c.e)) for _, s := range c.secrets { if strings.Contains(string(b), s) { t.Errorf("%s: the record carries %q: %s", c.name, s, b) } } } // A running server no file declares goes through the same redaction. out := correlateMCP(nil, []mcpProc{{PID: 7, PPID: 1, Argv: []string{"node", "/opt/demo/mcp-server.js", "postgresql://demo:ShadowPw77x@db.example.com/x"}}}, true) b, _ := json.Marshal(out) if strings.Contains(string(b), "ShadowPw77x") { t.Errorf("an undeclared server's record carries its password: %s", b) } // What is still kept: a reference to where the secret comes from, and // the URL's host. o := mcpAnalyze(mcpEntry{command: "demo-srv", args: []string{"--token", "${DEMO_TOKEN}", "https://mcp.example.com/sse"}}) if !strings.Contains(o.Command, "${DEMO_TOKEN}") || !strings.Contains(o.Command, "https://mcp.example.com") { t.Errorf("redaction took more than the secret: %q", o.Command) } } // One record, and the list, stay bounded however large the file's names are // and however many processes look like servers. func TestMCPRecordAndListStayBounded(t *testing.T) { env := map[string]string{} for i := 0; i < 5000; i++ { env[fmt.Sprintf("DEMO_VAR_%04d_%s", i, strings.Repeat("x", 200))] = "" } o := mcpAnalyze(mcpEntry{name: strings.Repeat("n", 100000), command: "npx", args: []string{"-y", strings.Repeat("p", 100000)}, env: env}) if b, _ := json.Marshal(o); len(b) > 8192 { t.Errorf("one stdio record is %d bytes", len(b)) } o = mcpAnalyze(mcpEntry{name: "r", url: "https://" + strings.Repeat("a", 100000) + ".example.com/x"}) if b, _ := json.Marshal(o); len(b) > 8192 { t.Errorf("one remote record is %d bytes", len(b)) } var procs []mcpProc for i := 0; i < 3000; i++ { procs = append(procs, mcpProc{PID: 10 + i, PPID: 1, Argv: []string{"demo-mcp-server"}}) } if out := correlateMCP(nil, procs, true); len(out) > mcpMaxServers { t.Errorf("%d records from undeclared servers; the list is capped at %d", len(out), mcpMaxServers) } } // A crafted config.toml nested thousands deep is refused, not walked on the // stack; a realistic depth still parses. func TestParseTOMLSubsetRefusesDeepNesting(t *testing.T) { for name, src := range map[string]string{ "arrays": "a = " + strings.Repeat("[", 5000) + strings.Repeat("]", 5000) + "\n", "inline tables": "a = " + strings.Repeat("{b = ", 5000) + "1" + strings.Repeat("}", 5000) + "\n", } { if _, err := parseTOMLSubset(src); err == nil { t.Errorf("%s nested 5000 deep parsed", name) } } if _, err := parseTOMLSubset("[mcp_servers.x]\nenv = { A = { B = [[1, 2], [3]] } }\n"); err != nil { t.Errorf("a realistic depth: %v", err) } } // macOS's nobody is uid -2 in its directory; it is a service account, not // an unread uid. func TestClassifyUnixOwnerNegativeDirectoryUID(t *testing.T) { f := classifyUnixOwner(unixOwner{UID: -2, Name: "nobody", SystemUIDMax: darwinSystemUIDMax, LocalRecord: ownerYes}) if f.Class != ownerClassService { t.Errorf("uid -2: %+v", f) } if f := classifyUnixOwner(unixOwner{UID: -1, Name: "demo"}); f.Class != ownerUnknown { t.Errorf("an unread uid: %+v", f) } } // dscl that was killed by the timeout has not said the record is missing: // only a real non-zero exit does. func TestDsclRecordMissingOnlyOnARealExit(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("needs sh") } _, killed := exec.Command("sh", "-c", "kill -9 $$").Output() _, missing := exec.Command("sh", "-c", "exit 56").Output() if dsclRecordMissing(killed) { t.Error("a killed dscl read as no local record") } if !dsclRecordMissing(missing) { t.Error("dscl exiting 56 did not read as no local record") } if dsclRecordMissing(nil) { t.Error("success read as no local record") } } // A read that stops answering (a home on a network mount) holds neither the // beat nor a second reader, and a finished read is reused for the cache // window instead of redone on every 60-second beat. func TestMCPServersReportNeverWaitsOnAStuckRead(t *testing.T) { oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout block := make(chan struct{}) t.Cleanup(func() { close(block) mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT resetMCPConfigCache() }) resetMCPConfigCache() var calls int32 mcpHomesFn = func() ([]mcpHome, bool) { atomic.AddInt32(&calls, 1) <-block return nil, true } mcpSystemFn = func() []mcpSource { return nil } scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, nil } mcpReadTimeout = 100 * time.Millisecond for beat := 0; beat < 3; beat++ { done := make(chan mcpConfigScan, 1) go func() { _, s := mcpServersReport(); done <- s }() select { case s := <-done: if !s.TimedOut || !s.Supported { t.Fatalf("beat %d: a read that has not finished must say so: %+v", beat, s) } case <-time.After(5 * time.Second): t.Fatalf("beat %d waited on a stuck read", beat) } } if n := atomic.LoadInt32(&calls); n != 1 { t.Fatalf("%d reads started while one was stuck", n) } resetMCPConfigCache() atomic.StoreInt32(&calls, 0) home := t.TempDir() mcpHomesFn = func() ([]mcpHome, bool) { atomic.AddInt32(&calls, 1) return []mcpHome{{Dir: home, ConfigDir: home}}, true } for beat := 0; beat < 3; beat++ { if _, s := mcpServersReport(); s.TimedOut { t.Fatalf("beat %d timed out: %+v", beat, s) } } if n := atomic.LoadInt32(&calls); n != 1 { t.Fatalf("the files were read %d times in one cache window", n) } } // A read stuck on a home that stopped answering delays ONE beat by at most // mcpReadTimeout; the beats after it do not wait on the same read again. And // until a read has finished, nothing is known to be declared, so no running // process may be reported as running_not_declared. func TestMCPStuckReadDelaysOneBeatAndClaimsNoShadows(t *testing.T) { oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout block := make(chan struct{}) t.Cleanup(func() { close(block) mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT resetMCPConfigCache() }) resetMCPConfigCache() mcpHomesFn = func() ([]mcpHome, bool) { <-block; return nil, true } mcpSystemFn = func() []mcpSource { return nil } scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return []mcpProc{{PID: 4242, PPID: 1, Argv: []string{"demo-mcp-server", "--stdio"}}}, nil } mcpReadTimeout = 400 * time.Millisecond start := time.Now() out, s := mcpServersReport() if took := time.Since(start); took < mcpReadTimeout/2 { t.Fatalf("the first beat did not wait for the read at all (%v)", took) } if !s.TimedOut || len(out) != 0 { t.Fatalf("before any read finished: timed_out %v, servers %+v", s.TimedOut, out) } for beat := 1; beat < 4; beat++ { start := time.Now() out, s := mcpServersReport() if took := time.Since(start); took > mcpReadTimeout/2 { t.Fatalf("beat %d waited %v on a read an earlier beat already waited out", beat, took) } if !s.TimedOut || len(out) != 0 { t.Fatalf("beat %d: timed_out %v, servers %+v", beat, s.TimedOut, out) } } }