Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 109 additions & 2 deletions crates/aep-core/src/mcp_headers.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,40 @@
pub use crate::evidence::McpHeaderRisk;

/// Check MCP-Method and MCP-Name header values for sensitive-data leakage patterns.
/// Check MCP-Method and MCP-Name header values for sensitive-data leakage patterns
/// as defined by the MCP 2026-07-28 specification.
///
/// Returns the highest-severity risk detected, or None if no risk is found.
/// Three detection layers are applied in priority order (first match wins):
///
/// 1. **Credential prefix detection** — both `mcp_method` and `mcp_name` are
/// checked (case-insensitive) against known credential prefixes
/// (`ghp_`, `ghb_`, `sk-`, `Bearer `, `token `, `api_`). A match yields
/// [`McpHeaderRisk::CredentialLeak`].
///
/// 2. **High-entropy value detection** — a contiguous alphanumeric run of
/// ≥ 32 characters in either header value suggests a leaked API key or
/// opaque token, yielding [`McpHeaderRisk::HighEntropyValue`].
///
/// 3. **PII detection** — the `mcp_name` value is checked for an email-like
/// pattern (contains both `@` and `.`), yielding [`McpHeaderRisk::PiiLeak`].
///
/// Returns the highest-severity [`McpHeaderRisk`] detected, or `None` if no
/// leakage pattern is found and both headers are absent or benign.
///
/// # Integration with `build_evidence`
///
/// The returned `Option<McpHeaderRisk>` is designed to pass directly as the
/// `mcp_header_risk` parameter of
/// [`proxy_wasm_evidence::recorder::build_evidence`], which converts the enum
/// to a `snake_case` string via [`McpHeaderRisk::as_str`] and stores it on
/// [`ActionEvidence::mcp_header_risk`]. This is the wiring used by
/// `EvidenceFilter::on_http_response_headers` in the Proxy-Wasm HTTP filter:
///
/// ```text
/// let risk = classify_mcp_headers(mcp_method, mcp_name);
/// let evidence = build_evidence(id, tool, &risk_ctx, ts, None, risk);
/// // ^^^
/// // Option<McpHeaderRisk> flows through
/// ```
pub fn classify_mcp_headers(
mcp_method: Option<&str>,
mcp_name: Option<&str>,
Expand Down Expand Up @@ -76,6 +108,18 @@ mod tests {
);
}

#[test]
fn classify_mcp_headers_credential_prefix_case_insensitive() {
assert_eq!(
classify_mcp_headers(Some("GHP_ABC123"), None),
Some(McpHeaderRisk::CredentialLeak)
);
assert_eq!(
classify_mcp_headers(Some("SK-abcdefghij"), None),
Some(McpHeaderRisk::CredentialLeak)
);
}

#[test]
fn classify_mcp_headers_clean_values_return_none() {
assert_eq!(
Expand All @@ -85,4 +129,67 @@ mod tests {
assert_eq!(classify_mcp_headers(None, None), None);
assert_eq!(classify_mcp_headers(Some("tools/list"), None), None);
}

#[test]
fn classify_mcp_headers_no_mcp_headers_returns_none() {
// Neither MCP-Method nor MCP-Name present — common for non-MCP traffic.
assert_eq!(classify_mcp_headers(None, None), None);
}

#[test]
fn classify_mcp_headers_partial_mcp_headers_benign_returns_none() {
// Only MCP-Method present, MCP-Name absent — partial MCP headers, clean values.
assert_eq!(classify_mcp_headers(Some("tools/call"), None), None);
assert_eq!(classify_mcp_headers(Some("resources/read"), None), None);

// Only MCP-Name present, MCP-Method absent — partial MCP headers, clean value.
assert_eq!(classify_mcp_headers(None, Some("my_tool")), None);
assert_eq!(classify_mcp_headers(None, Some("read_config")), None);
}

#[test]
fn classify_mcp_headers_partial_mcp_headers_leakage_detected() {
// Only MCP-Method present with credential prefix — leakage still detected.
assert_eq!(
classify_mcp_headers(Some("ghp_abc123"), None),
Some(McpHeaderRisk::CredentialLeak)
);

// Only MCP-Name present with PII — leakage still detected.
assert_eq!(
classify_mcp_headers(None, Some("user@example.com")),
Some(McpHeaderRisk::PiiLeak)
);
}

#[test]
fn classify_mcp_headers_complete_mcp_headers_benign_returns_none() {
// Both MCP-Method and MCP-Name present with clean values — no leakage.
assert_eq!(
classify_mcp_headers(Some("tools/call"), Some("read_file")),
None
);
assert_eq!(
classify_mcp_headers(Some("resources/read"), Some("my_resource")),
None
);
}

#[test]
fn classify_mcp_headers_complete_mcp_headers_leakage_in_method_detected() {
// Both headers present; MCP-Method carries a credential — credential wins.
assert_eq!(
classify_mcp_headers(Some("sk-secretkey"), Some("read_file")),
Some(McpHeaderRisk::CredentialLeak)
);
}

#[test]
fn classify_mcp_headers_complete_mcp_headers_leakage_in_name_detected() {
// Both headers present; MCP-Name carries PII — PII detected.
assert_eq!(
classify_mcp_headers(Some("tools/call"), Some("admin@corp.io")),
Some(McpHeaderRisk::PiiLeak)
);
}
}
Loading