mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:11:29 +00:00
23411bd6fc
Closes Audit-2026-04-25 H-002, H-003, M-003, M-004, M-005 (all CWE-1039 LLM Prompt Injection at the MCP↔consumer trust boundary, TB-7). Strategy: wrapper-layer fencing. All 87 MCP tools route their success path through textResult and their failure path through errorResult. By fencing at those two wrappers we cover every existing tool AND every future tool with a single change — no per-tool wiring required. What changed - internal/mcp/fence.go (new) — FenceUntrusted helper with strategy doc + per-finding rationale. Both fenceMCPResponse and fenceMCPError use it internally. - internal/mcp/tools.go — textResult wraps response body via fenceMCPResponse; errorResult wraps error string via fenceMCPError. - internal/mcp/tools_test.go — TestTextResult / TestErrorResult updated to assert fenced shape (start marker + end marker + inner body). - internal/mcp/injection_regression_test.go (new) — 5 regression test functions, one per audit finding, each replays 5 classic LLM injection payloads (instruction_override, system_role_spoofing, delimiter_break_attempt, markdown_link_phishing, data_exfil_via_url) and asserts the planted payload appears VERBATIM (preservation, operator visibility) INSIDE the fence boundaries. - internal/mcp/fence_guardrail_test.go (new) — CI guardrail that walks every non-test .go file in the mcp package and fails if it finds a bare gomcp.CallToolResult literal outside tools.go. Prevents future tools from silently bypassing the fence. Delimiter-forgery defense The naive constant fence (--- UNTRUSTED MCP_RESPONSE END ---) is forgeable: an attacker who controls a field value can plant the literal end marker and "break out" of the fence. Defense: every fence call generates a 6-byte crypto/rand nonce, hex-encoded, and embeds it in BOTH the START and END markers. An attacker would need to predict the nonce (2^48 search per fence) to forge a matching END inside the payload. The delimiter_break_attempt regression test exercises this. Per-finding mapping - H-002 Cert Subject DN injection (CSR submitter controlled) → TestMCP_PromptInjection_H002_CertSubjectDN - H-003 Discovered cert metadata injection (cert owner controlled) → TestMCP_PromptInjection_H003_DiscoveredCertMetadata - M-003 Agent heartbeat injection (agent self-reports hostname/OS/IP) → TestMCP_PromptInjection_M003_AgentHeartbeat - M-004 Upstream CA error injection (CA controls error string) → TestMCP_PromptInjection_M004_UpstreamCAError - M-005 Audit details + notification body injection (downstream actors control these) → TestMCP_PromptInjection_M005_AuditDetailsAndNotifications Verification gates - go vet ./... → clean - go build ./... → clean - go test -short -count=1 ./... → all packages pass - go test -count=1 ./internal/mcp/... → all packages pass - npx tsc --noEmit (web) → clean - npx vitest run (web) → 337 passed - python3 yaml.safe_load(api/openapi.yaml) → 89 paths, 56 schemas Threat-model placement: TB-7 (MCP↔LLM consumer). certctl owns the boundary; consumer-side prompt engineering is recommended but not relied upon. Defense-in-depth: per-call nonce closes the delimiter-forgery edge case that constant fences would have left exposed. Bundle 3 of the 2026-04-25 comprehensive audit (88 findings).
68 lines
2.3 KiB
Go
68 lines
2.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestFenceGuardrail_NoBareCallToolResult is the regression guardrail for
|
|
// Bundle-3 / Audit H-002, H-003, M-003, M-004, M-005 / CWE-1039 (LLM Prompt
|
|
// Injection).
|
|
//
|
|
// The wrapper-layer fencing strategy (textResult / errorResult in tools.go)
|
|
// only provides defense-in-depth if EVERY MCP tool routes its response
|
|
// through those wrappers. A new tool that constructs its own
|
|
// `gomcp.CallToolResult{...}` literal — or returns a bare `fmt.Errorf` from
|
|
// the tool handler signature — would silently bypass the fence and re-open
|
|
// every finding in this bundle.
|
|
//
|
|
// This guardrail walks every .go file in the mcp package and fails CI if it
|
|
// finds a `gomcp.CallToolResult{` literal outside `tools.go` (which defines
|
|
// textResult). It is intentionally cheap and string-based — a real Go AST
|
|
// scan would be more precise but would also be more brittle to refactor.
|
|
//
|
|
// To add a new MCP tool: route through textResult / errorResult and this
|
|
// test stays green. To deliberately bypass: explicitly add the file to the
|
|
// allowlist below with a comment explaining why.
|
|
func TestFenceGuardrail_NoBareCallToolResult(t *testing.T) {
|
|
// Files allowed to construct CallToolResult directly.
|
|
// tools.go defines the textResult wrapper and is the ONLY legitimate
|
|
// site. Tests are also allowed (they exercise the wrapper output).
|
|
allow := map[string]bool{
|
|
"tools.go": true,
|
|
}
|
|
|
|
entries, err := os.ReadDir(".")
|
|
if err != nil {
|
|
t.Fatalf("read package dir: %v", err)
|
|
}
|
|
violations := []string{}
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasSuffix(name, ".go") {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(name, "_test.go") {
|
|
continue
|
|
}
|
|
if allow[name] {
|
|
continue
|
|
}
|
|
body, err := os.ReadFile(filepath.Join(".", name))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", name, err)
|
|
}
|
|
text := string(body)
|
|
if strings.Contains(text, "gomcp.CallToolResult{") ||
|
|
strings.Contains(text, "mcp.CallToolResult{") {
|
|
violations = append(violations, name+": constructs CallToolResult literal — must route through textResult/errorResult (Bundle-3 fence)")
|
|
}
|
|
}
|
|
if len(violations) > 0 {
|
|
t.Errorf("Bundle-3 fence guardrail violated. Add allowlist entry only with security review.\n - %s",
|
|
strings.Join(violations, "\n - "))
|
|
}
|
|
}
|