Phase 4's detection core, as a standalone crate. This is the part with no
competitor on Linux, and deliberately the part with no Linux in it —
file parsing and logic only, no fanotify, no /proc, no eBPF — so the
macOS and Windows port is weeks rather than a second product.
Five detectors, 88 tests:
pickle GLOBAL/STACK_GLOBAL walk over .pt/.ckpt/.pkl/.joblib.
torch.load runs a stack machine; a model file is a
program and downloading weights is a code-execution
decision.
injection Instructions aimed at a coding agent in CLAUDE.md,
AGENTS.md, .cursorrules, copilot-instructions.
installscript preinstall/postinstall hooks that curl|sh, decode and
run, reach for credentials, or install persistence.
typosquat Damerau-Levenshtein against popular names, plus
slopsquat detection: new + near-zero downloads + one
edit from something popular is the signature of a name
a model invented and somebody then registered.
mcp Servers fetched unpinned at launch, handed secrets, or
pointed at $HOME or credential paths.
Wired through `supply.sweep` on the socket and `hound supply-chain
<path>`, which exits 1 on a critical so it drops into CI.
Three things worth recording:
* Scoring is by independent category, not by keyword count. One
suspicious phrase is a phrase; two categories at once is an attack.
A file that only says "ignore previous instructions about formatting"
is a warning, not a critical.
* Proximity matters more than presence. The first version flagged an
entirely ordinary conventions file, because it mentioned ".env" in
one paragraph and "prefer small commits" in another. A credential and
a movement verb now have to appear within a sentence of each other.
The test that caught it is kept as the regression.
* Pickle call detection has to come out of the opcode walk, not a byte
search. REDUCE, INST and OBJ are the ASCII letters R, i and o, which
also occur inside every string the stream carries — searching raw
bytes finds the o in "os" and reports a call that never happens,
turning every warning into a critical.
Every finding carries a plain-language explanation and a next step, and
there is a test asserting explanations do not leak rule identifiers or
jargon. The audience includes people who cannot triage a YARA match and
should never be shown one.
Verified against a demo project holding a squatted @vue plugin with a
curl|sh postinstall, a poisoned CLAUDE.md in a vendored repo, an
unpinned MCP server holding a GitHub token, and a pickle calling
os.system — four criticals and one warning, while the legitimate
CLAUDE.md, the real express manifest and the properly-scoped MCP server
beside them stayed clean.
189 tests pass across the workspace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
12 KiB
Rust
350 lines
12 KiB
Rust
//! MCP server audit.
|
|
//!
|
|
//! An MCP server is a program your coding agent starts and then trusts
|
|
//! with tools. The usual way to add one is a line of JSON containing
|
|
//! `npx some-package`, which means: fetch code from a registry and run it,
|
|
//! with your agent's confidence and your user's permissions.
|
|
//!
|
|
//! Nothing scans these today. There is no registry review, no reputation
|
|
//! signal, and the config file lives somewhere nobody looks after the day
|
|
//! they set it up. That is a wide-open door, and it is open on macOS and
|
|
//! Windows exactly as much as on Linux.
|
|
//!
|
|
//! What we check is the shape of the entry, not the behaviour of the
|
|
//! server — behaviour needs the runtime watch that Phase 6 adds. Even so,
|
|
//! the shape says a lot: whether the code is pinned, whether it is fetched
|
|
//! fresh on every launch, what it is handed in its environment, and
|
|
//! whether the command line names things a tool server has no business
|
|
//! reading.
|
|
|
|
use crate::{Finding, Severity};
|
|
|
|
/// One server entry from an MCP config.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Server {
|
|
pub name: String,
|
|
pub command: String,
|
|
pub args: Vec<String>,
|
|
/// Environment variable names (not values — we never read secrets).
|
|
pub env_keys: Vec<String>,
|
|
}
|
|
|
|
impl Server {
|
|
/// The whole invocation, for pattern matching.
|
|
fn command_line(&self) -> String {
|
|
format!("{} {}", self.command, self.args.join(" ")).to_ascii_lowercase()
|
|
}
|
|
|
|
/// Fetched from a registry at launch rather than installed and pinned.
|
|
fn is_fetched_at_launch(&self) -> bool {
|
|
let c = self.command.to_ascii_lowercase();
|
|
let runner = matches!(
|
|
c.rsplit('/').next().unwrap_or(&c),
|
|
"npx" | "bunx" | "uvx" | "pnpx" | "dlx"
|
|
);
|
|
// `npx -y` skips even the "is this what you meant?" prompt.
|
|
runner
|
|
}
|
|
|
|
/// A package spec with no version is whatever the registry serves
|
|
/// today, which may not be what it served yesterday.
|
|
fn is_unpinned(&self) -> bool {
|
|
if !self.is_fetched_at_launch() {
|
|
return false;
|
|
}
|
|
// The first argument that is not a flag is the package spec.
|
|
self.args
|
|
.iter()
|
|
.find(|a| !a.starts_with('-'))
|
|
.map(|spec| {
|
|
// scoped names carry a leading @, so only a later @ pins it
|
|
let after_scope = spec.strip_prefix('@').unwrap_or(spec);
|
|
!after_scope.contains('@')
|
|
})
|
|
.unwrap_or(true)
|
|
}
|
|
}
|
|
|
|
const CREDENTIAL_PATHS: &[&str] = &[
|
|
".ssh", "id_rsa", "id_ed25519", ".aws", ".gnupg", ".netrc", ".npmrc",
|
|
".pypirc", "credentials", ".kube", ".docker/config",
|
|
];
|
|
|
|
/// Environment keys that hand a server a live secret.
|
|
const SECRET_KEYS: &[&str] = &[
|
|
"token", "secret", "password", "passwd", "api_key", "apikey",
|
|
"private_key", "credential", "session",
|
|
];
|
|
|
|
/// Directories broad enough that "filesystem access" means "everything".
|
|
const BROAD_ROOTS: &[&str] = &["/", "/home", "$home", "~", "~/", "/etc", "/var"];
|
|
|
|
/// Audit one server entry.
|
|
pub fn scan_server(s: &Server, location: &str) -> Vec<Finding> {
|
|
let mut out = Vec::new();
|
|
let cmdline = s.command_line();
|
|
|
|
if CREDENTIAL_PATHS.iter().any(|p| cmdline.contains(p)) {
|
|
out.push(Finding::new(
|
|
"mcp-credential-scope",
|
|
Severity::Critical,
|
|
s.name.clone(),
|
|
location,
|
|
format!(
|
|
"The \"{}\" tool server is started with your credential files in its \
|
|
arguments. Anything it is given, it can read — and an assistant will \
|
|
call its tools without asking you first.",
|
|
s.name
|
|
),
|
|
"hound-mcp-credentials-a",
|
|
"Remove this server unless you are certain you need it, and narrow what it \
|
|
is pointed at. Nothing that talks to an assistant should be handed your keys.",
|
|
));
|
|
}
|
|
|
|
if s.args.iter().any(|a| BROAD_ROOTS.contains(&a.to_ascii_lowercase().as_str())) {
|
|
out.push(Finding::new(
|
|
"mcp-broad-scope",
|
|
Severity::Warning,
|
|
s.name.clone(),
|
|
location,
|
|
format!(
|
|
"The \"{}\" tool server is pointed at your whole home directory or the \
|
|
root of the filesystem. Whatever it can reach, your assistant can reach \
|
|
through it.",
|
|
s.name
|
|
),
|
|
"hound-mcp-overreach-a",
|
|
"Point it at the specific project directory you want it to work in.",
|
|
));
|
|
}
|
|
|
|
if s.is_unpinned() {
|
|
out.push(Finding::new(
|
|
"mcp-unpinned",
|
|
Severity::Warning,
|
|
s.name.clone(),
|
|
location,
|
|
format!(
|
|
"The \"{}\" tool server downloads its code fresh from the internet every \
|
|
time it starts, and no version is fixed. Whoever controls that package \
|
|
can change what runs on your machine at any moment, without you \
|
|
installing anything.",
|
|
s.name
|
|
),
|
|
"hound-mcp-unpinned-a",
|
|
"Pin a version, or install the server properly and run the installed copy.",
|
|
));
|
|
}
|
|
|
|
let secrets: Vec<&String> = s
|
|
.env_keys
|
|
.iter()
|
|
.filter(|k| {
|
|
let l = k.to_ascii_lowercase();
|
|
SECRET_KEYS.iter().any(|s| l.contains(s))
|
|
})
|
|
.collect();
|
|
if !secrets.is_empty() && s.is_fetched_at_launch() {
|
|
let names: Vec<&str> = secrets.iter().map(|s| s.as_str()).collect();
|
|
out.push(Finding::new(
|
|
"mcp-secret-to-unpinned",
|
|
Severity::Critical,
|
|
s.name.clone(),
|
|
location,
|
|
format!(
|
|
"The \"{}\" tool server is handed {} — and its code is downloaded fresh \
|
|
from the internet on every launch. A change to that package would hand \
|
|
your secret to whoever made the change.",
|
|
s.name,
|
|
names.join(", ")
|
|
),
|
|
"hound-mcp-secret-unpinned-a",
|
|
"Pin the version, or install the server locally. Then rotate the secret if \
|
|
you have been running it unpinned.",
|
|
));
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
/// Parse and audit an MCP config file.
|
|
///
|
|
/// Handles both shapes in the wild: a top-level `mcpServers` object
|
|
/// (Claude Desktop, Cursor) and a bare `servers` object.
|
|
pub fn scan_config(json: &str, location: &str) -> Vec<Finding> {
|
|
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else {
|
|
return Vec::new();
|
|
};
|
|
let servers = v
|
|
.get("mcpServers")
|
|
.or_else(|| v.get("servers"))
|
|
.and_then(|s| s.as_object());
|
|
let Some(servers) = servers else {
|
|
return Vec::new();
|
|
};
|
|
|
|
let mut out = Vec::new();
|
|
for (name, entry) in servers {
|
|
let command = entry
|
|
.get("command")
|
|
.and_then(|c| c.as_str())
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
let args = entry
|
|
.get("args")
|
|
.and_then(|a| a.as_array())
|
|
.map(|a| {
|
|
a.iter()
|
|
.filter_map(|x| x.as_str().map(str::to_string))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let env_keys = entry
|
|
.get("env")
|
|
.and_then(|e| e.as_object())
|
|
.map(|e| e.keys().cloned().collect())
|
|
.unwrap_or_default();
|
|
|
|
out.extend(scan_server(
|
|
&Server { name: name.clone(), command, args, env_keys },
|
|
location,
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn server(cmd: &str, args: &[&str]) -> Server {
|
|
Server {
|
|
name: "test-server".into(),
|
|
command: cmd.into(),
|
|
args: args.iter().map(|s| s.to_string()).collect(),
|
|
env_keys: Vec::new(),
|
|
}
|
|
}
|
|
|
|
// ── pinning ──
|
|
|
|
#[test]
|
|
fn npx_without_a_version_is_unpinned() {
|
|
assert!(server("npx", &["-y", "mcp-github-tools"]).is_unpinned());
|
|
}
|
|
|
|
#[test]
|
|
fn npx_with_a_version_is_pinned() {
|
|
assert!(!server("npx", &["-y", "mcp-github-tools@0.3.1"]).is_unpinned());
|
|
}
|
|
|
|
#[test]
|
|
fn a_scoped_package_needs_a_version_after_the_scope() {
|
|
assert!(server("npx", &["@acme/mcp-tools"]).is_unpinned());
|
|
assert!(!server("npx", &["@acme/mcp-tools@1.2.3"]).is_unpinned());
|
|
}
|
|
|
|
#[test]
|
|
fn an_installed_binary_is_not_fetched_at_launch() {
|
|
let s = server("/usr/local/bin/my-mcp-server", &["--root", "/srv/project"]);
|
|
assert!(!s.is_fetched_at_launch());
|
|
assert!(!s.is_unpinned());
|
|
}
|
|
|
|
#[test]
|
|
fn other_runners_count_too() {
|
|
for runner in ["bunx", "uvx", "pnpx"] {
|
|
assert!(server(runner, &["thing"]).is_unpinned(), "{runner}");
|
|
}
|
|
}
|
|
|
|
// ── must catch ──
|
|
|
|
#[test]
|
|
fn credentials_on_the_command_line_are_critical() {
|
|
let f = scan_server(&server("npx", &["mcp-fs", "/home/joe/.ssh"]), "mcp.json");
|
|
assert!(f.iter().any(|x| x.severity == Severity::Critical));
|
|
assert!(f.iter().any(|x| x.kind == "mcp-credential-scope"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_server_pointed_at_home_is_flagged() {
|
|
let f = scan_server(&server("npx", &["mcp-filesystem@1.0.0", "$HOME"]), "mcp.json");
|
|
assert!(f.iter().any(|x| x.kind == "mcp-broad-scope"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_secret_handed_to_unpinned_code_is_critical() {
|
|
let mut s = server("npx", &["-y", "mcp-github-tools"]);
|
|
s.env_keys = vec!["GITHUB_TOKEN".into()];
|
|
let f = scan_server(&s, "mcp.json");
|
|
let hit = f.iter().find(|x| x.kind == "mcp-secret-to-unpinned").expect("must fire");
|
|
assert_eq!(hit.severity, Severity::Critical);
|
|
assert!(hit.advice.contains("rotate"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_secret_handed_to_pinned_local_code_is_not_flagged() {
|
|
let mut s = server("/usr/local/bin/mcp-github", &["--repo", "acme/app"]);
|
|
s.env_keys = vec!["GITHUB_TOKEN".into()];
|
|
let f = scan_server(&s, "mcp.json");
|
|
assert!(
|
|
!f.iter().any(|x| x.kind == "mcp-secret-to-unpinned"),
|
|
"an installed, pinned server holding a token is normal"
|
|
);
|
|
}
|
|
|
|
// ── must NOT catch ──
|
|
|
|
#[test]
|
|
fn a_well_configured_server_is_clean() {
|
|
let s = server("/usr/local/bin/mcp-project", &["--root", "/home/joe/src/app"]);
|
|
assert!(scan_server(&s, "mcp.json").is_empty());
|
|
}
|
|
|
|
// ── config parsing ──
|
|
|
|
#[test]
|
|
fn parses_the_claude_desktop_shape() {
|
|
let json = r#"{
|
|
"mcpServers": {
|
|
"github": {
|
|
"command": "npx",
|
|
"args": ["-y", "mcp-github-tools"],
|
|
"env": {"GITHUB_TOKEN": "ghp_x"}
|
|
}
|
|
}
|
|
}"#;
|
|
let f = scan_config(json, "~/.config/mcp/servers.json");
|
|
assert!(f.iter().any(|x| x.kind == "mcp-unpinned"));
|
|
assert!(f.iter().any(|x| x.kind == "mcp-secret-to-unpinned"));
|
|
assert!(f.iter().all(|x| x.subject == "github"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_the_bare_servers_shape() {
|
|
let json = r#"{"servers": {"fs": {"command": "npx", "args": ["mcp-fs", "/"]}}}"#;
|
|
let f = scan_config(json, "mcp.json");
|
|
assert!(f.iter().any(|x| x.kind == "mcp-broad-scope"));
|
|
}
|
|
|
|
#[test]
|
|
fn secret_values_are_never_read() {
|
|
// We take env KEYS only. A finding that quoted the token would put
|
|
// the secret in a log file, which is its own vulnerability.
|
|
let json = r#"{"mcpServers":{"g":{"command":"npx","args":["x"],"env":{"API_KEY":"sk-live-SECRET"}}}}"#;
|
|
let f = scan_config(json, "mcp.json");
|
|
for finding in &f {
|
|
assert!(!finding.explanation.contains("sk-live-SECRET"));
|
|
assert!(!finding.subject.contains("sk-live-SECRET"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_config_does_not_panic_or_accuse() {
|
|
assert!(scan_config("{ not json", "mcp.json").is_empty());
|
|
assert!(scan_config("{}", "mcp.json").is_empty());
|
|
assert!(scan_config("", "mcp.json").is_empty());
|
|
}
|
|
}
|