tessen-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tessen/__init__.py +1 -0
- tessen/cli.py +206 -0
- tessen/models.py +57 -0
- tessen/output/__init__.py +0 -0
- tessen/output/human.py +98 -0
- tessen/output/json_output.py +68 -0
- tessen/parsers/__init__.py +0 -0
- tessen/parsers/auto.py +77 -0
- tessen/parsers/claude_code.py +42 -0
- tessen/parsers/claude_desktop.py +22 -0
- tessen/parsers/common.py +66 -0
- tessen/parsers/cursor.py +38 -0
- tessen/rules/__init__.py +0 -0
- tessen/rules/base.py +29 -0
- tessen/rules/builtin/__init__.py +30 -0
- tessen/rules/builtin/auto_approve.py +98 -0
- tessen/rules/builtin/dangerous_command.py +89 -0
- tessen/rules/builtin/hook_injection.py +153 -0
- tessen/rules/builtin/http_no_auth.py +99 -0
- tessen/rules/builtin/known_vulnerable.py +159 -0
- tessen/rules/builtin/secrets_in_env.py +112 -0
- tessen/rules/builtin/suspicious_args.py +91 -0
- tessen/rules/engine.py +36 -0
- tessen/rules/models.py +52 -0
- tessen_cli-0.1.0.dist-info/METADATA +301 -0
- tessen_cli-0.1.0.dist-info/RECORD +28 -0
- tessen_cli-0.1.0.dist-info/WHEEL +4 -0
- tessen_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Detect plaintext secrets in MCP server environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from tessen.models import ParseResult
|
|
8
|
+
from tessen.rules.base import Rule
|
|
9
|
+
from tessen.rules.models import Finding, RuleMetadata, Severity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Known secret prefixes/patterns from real-world providers.
|
|
13
|
+
# Each entry: (pattern, provider_name, example)
|
|
14
|
+
SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
15
|
+
(re.compile(r"^ghp_[A-Za-z0-9]{36,}$"), "GitHub Personal Access Token"),
|
|
16
|
+
(re.compile(r"^github_pat_[A-Za-z0-9_]{80,}$"), "GitHub Fine-Grained PAT"),
|
|
17
|
+
(re.compile(r"^gho_[A-Za-z0-9]{36,}$"), "GitHub OAuth Token"),
|
|
18
|
+
(re.compile(r"^sk-[A-Za-z0-9]{20,}$"), "OpenAI/Anthropic-style API key"),
|
|
19
|
+
(re.compile(r"^sk-ant-api03-[A-Za-z0-9_-]{80,}$"), "Anthropic API key"),
|
|
20
|
+
(re.compile(r"^AIza[A-Za-z0-9_-]{35}$"), "Google API key"),
|
|
21
|
+
(re.compile(r"^AKIA[A-Z0-9]{16}$"), "AWS Access Key ID"),
|
|
22
|
+
(re.compile(r"^xox[baprs]-[A-Za-z0-9-]{10,}$"), "Slack token"),
|
|
23
|
+
(re.compile(r"^lin_api_[A-Za-z0-9]{40,}$"), "Linear API key"),
|
|
24
|
+
(re.compile(r"^SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$"), "SendGrid API key"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Env var names that suggest a secret, even if the value doesn't match a known pattern.
|
|
28
|
+
SUSPICIOUS_NAMES = re.compile(
|
|
29
|
+
r"(?i)(api[_-]?key|token|secret|password|passwd|auth|credential|private[_-]?key)"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Values obviously safe (placeholders, references, empty).
|
|
33
|
+
SAFE_VALUE_PATTERNS = [
|
|
34
|
+
re.compile(r"^\$\{.+\}$"), # ${VAR_NAME} substitution
|
|
35
|
+
re.compile(r"^\$[A-Z_][A-Z0-9_]*$"), # $VAR_NAME
|
|
36
|
+
re.compile(r"^<.*>$"), # <YOUR_TOKEN>
|
|
37
|
+
re.compile(r"REDACTED|PLACEHOLDER|EXAMPLE|CHANGEME|TODO", re.IGNORECASE),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SecretsInEnvRule(Rule):
|
|
42
|
+
METADATA = RuleMetadata(
|
|
43
|
+
id="tessen-secrets-in-env",
|
|
44
|
+
name="Plaintext secret in environment variable",
|
|
45
|
+
description=(
|
|
46
|
+
"MCP server configuration contains what appears to be a plaintext "
|
|
47
|
+
"credential (API key, token, or password) in an env var. Anyone with "
|
|
48
|
+
"read access to this config file can extract the secret."
|
|
49
|
+
),
|
|
50
|
+
severity=Severity.CRITICAL,
|
|
51
|
+
references=[
|
|
52
|
+
"https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length",
|
|
53
|
+
"https://docs.anthropic.com/en/docs/build-with-claude/mcp",
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def check(self, result: ParseResult) -> list[Finding]:
|
|
58
|
+
findings: list[Finding] = []
|
|
59
|
+
|
|
60
|
+
for server_name, server in result.config.mcpServers.items():
|
|
61
|
+
for env_name, env_value in server.env.items():
|
|
62
|
+
if not env_value or self._is_safe_value(env_value):
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
matched_provider = self._match_known_pattern(env_value)
|
|
66
|
+
is_suspicious_name = bool(SUSPICIOUS_NAMES.search(env_name))
|
|
67
|
+
|
|
68
|
+
if matched_provider is None and not is_suspicious_name:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
if matched_provider is not None:
|
|
72
|
+
title = f"{matched_provider} exposed in plaintext"
|
|
73
|
+
message = (
|
|
74
|
+
f"The env var '{env_name}' in server '{server_name}' contains "
|
|
75
|
+
f"a value matching a {matched_provider} pattern. Anyone reading "
|
|
76
|
+
f"this config can use it."
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
title = f"Possible secret in env var '{env_name}'"
|
|
80
|
+
message = (
|
|
81
|
+
f"The env var '{env_name}' in server '{server_name}' has a name "
|
|
82
|
+
f"suggesting it holds a credential, and the value is not a "
|
|
83
|
+
f"placeholder or variable reference."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
findings.append(
|
|
87
|
+
Finding(
|
|
88
|
+
rule_id=self.METADATA.id,
|
|
89
|
+
severity=self.METADATA.severity,
|
|
90
|
+
title=title,
|
|
91
|
+
message=message,
|
|
92
|
+
location=f"mcpServers.{server_name}.env.{env_name}",
|
|
93
|
+
remediation=(
|
|
94
|
+
"Move the secret to a secure store (1Password, Vault, "
|
|
95
|
+
"or OS keychain) and reference it via ${VAR_NAME} substitution."
|
|
96
|
+
),
|
|
97
|
+
references=self.METADATA.references,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return findings
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _is_safe_value(value: str) -> bool:
|
|
105
|
+
return any(p.search(value) for p in SAFE_VALUE_PATTERNS)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _match_known_pattern(value: str) -> str | None:
|
|
109
|
+
for pattern, provider in SECRET_PATTERNS:
|
|
110
|
+
if pattern.match(value):
|
|
111
|
+
return provider
|
|
112
|
+
return None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Detect suspicious patterns in MCP server arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import unicodedata
|
|
6
|
+
|
|
7
|
+
from tessen.models import ParseResult
|
|
8
|
+
from tessen.rules.base import Rule
|
|
9
|
+
from tessen.rules.models import Finding, RuleMetadata, Severity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Unicode categories associated with invisible / bidirectional / control text
|
|
13
|
+
# that can hide malicious instructions.
|
|
14
|
+
INVISIBLE_UNICODE_CATEGORIES = {"Cf", "Cc"} # Format, Control
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Args that grant filesystem-wide access.
|
|
18
|
+
DANGEROUS_PATH_ARGS = {"/", "/etc", "/root", "/home", "~", "C:\\", "C:/"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SuspiciousArgsRule(Rule):
|
|
22
|
+
METADATA = RuleMetadata(
|
|
23
|
+
id="tessen-suspicious-args",
|
|
24
|
+
name="Suspicious pattern in MCP server arguments",
|
|
25
|
+
description=(
|
|
26
|
+
"MCP server arguments contain hidden Unicode characters (potential prompt "
|
|
27
|
+
"injection carriers) or grant overly broad filesystem access."
|
|
28
|
+
),
|
|
29
|
+
severity=Severity.HIGH,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def check(self, result: ParseResult) -> list[Finding]:
|
|
33
|
+
findings: list[Finding] = []
|
|
34
|
+
|
|
35
|
+
for server_name, server in result.config.mcpServers.items():
|
|
36
|
+
for i, arg in enumerate(server.args):
|
|
37
|
+
if hidden := self._find_invisible_unicode(arg):
|
|
38
|
+
findings.append(
|
|
39
|
+
Finding(
|
|
40
|
+
rule_id=self.METADATA.id,
|
|
41
|
+
severity=Severity.CRITICAL,
|
|
42
|
+
title="Invisible Unicode in server argument",
|
|
43
|
+
message=(
|
|
44
|
+
f"Argument {i} of server '{server_name}' contains "
|
|
45
|
+
f"{len(hidden)} invisible Unicode character(s): "
|
|
46
|
+
f"{', '.join(hidden)}. These can hide instructions "
|
|
47
|
+
f"that the agent will read as prompt input."
|
|
48
|
+
),
|
|
49
|
+
location=f"mcpServers.{server_name}.args[{i}]",
|
|
50
|
+
remediation=(
|
|
51
|
+
"Inspect the argument character-by-character. Remove all "
|
|
52
|
+
"characters in Unicode categories Cf (Format) and Cc (Control)."
|
|
53
|
+
),
|
|
54
|
+
references=self.METADATA.references,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if arg.strip() in DANGEROUS_PATH_ARGS:
|
|
59
|
+
findings.append(
|
|
60
|
+
Finding(
|
|
61
|
+
rule_id=self.METADATA.id,
|
|
62
|
+
severity=Severity.HIGH,
|
|
63
|
+
title="Server granted filesystem-wide access",
|
|
64
|
+
message=(
|
|
65
|
+
f"Argument {i} of server '{server_name}' is '{arg}', "
|
|
66
|
+
f"granting the server access to the entire filesystem "
|
|
67
|
+
f"or user home. Any prompt injection reaching this "
|
|
68
|
+
f"server can read or modify sensitive files."
|
|
69
|
+
),
|
|
70
|
+
location=f"mcpServers.{server_name}.args[{i}]",
|
|
71
|
+
remediation=(
|
|
72
|
+
"Scope the server to the minimum necessary directory. "
|
|
73
|
+
"Prefer per-project paths over home or filesystem roots."
|
|
74
|
+
),
|
|
75
|
+
references=self.METADATA.references,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return findings
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _find_invisible_unicode(text: str) -> list[str]:
|
|
83
|
+
"""Return a list of hex codepoints for invisible characters in text."""
|
|
84
|
+
hidden = []
|
|
85
|
+
for ch in text:
|
|
86
|
+
if unicodedata.category(ch) in INVISIBLE_UNICODE_CATEGORIES:
|
|
87
|
+
# Skip common whitespace controls (\t, \n) that aren't attacks.
|
|
88
|
+
if ch in ("\t", "\n", "\r"):
|
|
89
|
+
continue
|
|
90
|
+
hidden.append(f"U+{ord(ch):04X}")
|
|
91
|
+
return hidden
|
tessen/rules/engine.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The rule engine — runs a set of rules against a ParseResult."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tessen.models import ParseResult
|
|
6
|
+
from tessen.rules.base import Rule
|
|
7
|
+
from tessen.rules.models import Finding
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RuleEngine:
|
|
11
|
+
"""Runs rules against parse results and collects findings."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, rules: list[Rule]) -> None:
|
|
14
|
+
self.rules = rules
|
|
15
|
+
|
|
16
|
+
def run(self, result: ParseResult) -> list[Finding]:
|
|
17
|
+
"""Run all rules against a single parse result. Returns sorted findings."""
|
|
18
|
+
findings: list[Finding] = []
|
|
19
|
+
for rule in self.rules:
|
|
20
|
+
try:
|
|
21
|
+
findings.extend(rule.check(result))
|
|
22
|
+
except Exception as e:
|
|
23
|
+
# A buggy rule should never crash the whole scan.
|
|
24
|
+
# Log and continue — but re-raise in dev mode later.
|
|
25
|
+
import warnings
|
|
26
|
+
|
|
27
|
+
warnings.warn(f"Rule '{rule.id}' raised {type(e).__name__}: {e}")
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
# Sort: highest severity first, then by rule_id, then by location.
|
|
31
|
+
findings.sort(key=lambda f: (-f.severity.rank, f.rule_id, f.location))
|
|
32
|
+
return findings
|
|
33
|
+
|
|
34
|
+
def run_many(self, results: list[ParseResult]) -> dict[str, list[Finding]]:
|
|
35
|
+
"""Run rules against multiple parse results. Keyed by source_path."""
|
|
36
|
+
return {str(r.source_path): self.run(r) for r in results}
|
tessen/rules/models.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Data models for detection rules and findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(str, Enum):
|
|
11
|
+
"""Finding severity levels."""
|
|
12
|
+
|
|
13
|
+
CRITICAL = "critical"
|
|
14
|
+
HIGH = "high"
|
|
15
|
+
MEDIUM = "medium"
|
|
16
|
+
LOW = "low"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def rank(self) -> int:
|
|
20
|
+
"""Numeric rank for sorting (higher = more severe)."""
|
|
21
|
+
return {"critical": 4, "high": 3, "medium": 2, "low": 1}[self.value]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Finding(BaseModel):
|
|
25
|
+
"""A single security finding produced by a rule."""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(frozen=True)
|
|
28
|
+
|
|
29
|
+
rule_id: str = Field(..., description="Unique identifier of the rule that fired")
|
|
30
|
+
severity: Severity
|
|
31
|
+
title: str = Field(..., description="Short human-readable title")
|
|
32
|
+
message: str = Field(..., description="Detailed description of what was found")
|
|
33
|
+
location: str = Field(
|
|
34
|
+
...,
|
|
35
|
+
description="Where in the config the issue lives (e.g. 'mcpServers.github.env.GITHUB_PAT')",
|
|
36
|
+
)
|
|
37
|
+
remediation: str | None = Field(None, description="How to fix it, in one sentence")
|
|
38
|
+
references: list[str] = Field(
|
|
39
|
+
default_factory=list, description="URLs to CVEs, advisories, or docs"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RuleMetadata(BaseModel):
|
|
44
|
+
"""Metadata every rule must define."""
|
|
45
|
+
|
|
46
|
+
id: str = Field(
|
|
47
|
+
..., pattern=r"^tessen-[a-z0-9-]+$", description="e.g. 'tessen-secrets-in-env'"
|
|
48
|
+
)
|
|
49
|
+
name: str
|
|
50
|
+
description: str
|
|
51
|
+
severity: Severity
|
|
52
|
+
references: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tessen-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Security scanner for AI agent configurations (MCP servers, Claude Desktop, Cursor, Claude Code, Windsurf).
|
|
5
|
+
Keywords: security,scanner,mcp,ai-agents,claude,cursor,devsecops
|
|
6
|
+
Author: Slim Ben Tanfous
|
|
7
|
+
Author-email: Slim Ben Tanfous <slimbentanfous2002@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Dist: pydantic>=2.13.4
|
|
20
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
21
|
+
Requires-Dist: rich>=15.0.0
|
|
22
|
+
Requires-Dist: typer>=0.26.7
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Project-URL: Homepage, https://github.com/SlimBenTanfous1/tessen-cli
|
|
25
|
+
Project-URL: Repository, https://github.com/SlimBenTanfous1/tessen-cli
|
|
26
|
+
Project-URL: Issues, https://github.com/SlimBenTanfous1/tessen-cli/issues
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# tessen
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
**Security scanner for AI agent configurations.**
|
|
34
|
+
|
|
35
|
+
Tessen scans MCP (Model Context Protocol) configurations — the files that tell Claude Desktop, Claude Code, Cursor, and Windsurf which tools to load — for vulnerabilities that current tooling misses: plaintext credentials, dangerous invocation patterns, invisible Unicode instruction carriers, and filesystem-wide access grants.
|
|
36
|
+
|
|
37
|
+
Point it at your config, get findings in seconds, wire it into CI to keep them from coming back.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
tessen scan
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
> Named after the Japanese 鉄扇 (_tessen_), an iron war fan carried into places swords were forbidden. It looked like an ordinary fan. It was a weapon. So are the MCP servers hiding in your `claude_desktop_config.json`.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Status
|
|
48
|
+
|
|
49
|
+
**Alpha** (`v0.1.0`). The scanner works, the tests pass, but it hasn't been published to PyPI yet and the ruleset is small. Expect breaking changes to the rule format and CLI flags before `v0.2`. If you use it, run it locally, don't wire it into production CI yet.
|
|
50
|
+
|
|
51
|
+
Current coverage: **3 built-in rules**, **3 client formats**, **26 tests**.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## What it catches
|
|
56
|
+
|
|
57
|
+
Every finding includes a severity, location in the config, message explaining the issue, and a remediation suggestion.
|
|
58
|
+
|
|
59
|
+
### `tessen-secrets-in-env` — plaintext credentials
|
|
60
|
+
|
|
61
|
+
Detects API keys and tokens stored directly in MCP server environment variables. Recognizes patterns from GitHub (`ghp_`, `github_pat_`, `gho_`), Anthropic, OpenAI, AWS access keys, Google API keys, Slack tokens, Linear, and SendGrid. Distinguishes real secrets from environment variable references (`${VAR}`), placeholders (`<YOUR_KEY>`), and common redaction markers (`REDACTED`, `PLACEHOLDER`).
|
|
62
|
+
|
|
63
|
+
**Severity:** `critical`
|
|
64
|
+
|
|
65
|
+
### `tessen-dangerous-command` — shell wrappers and curl-pipe-shell
|
|
66
|
+
|
|
67
|
+
Flags MCP servers invoked through a shell interpreter (`bash`, `sh`, `zsh`, `pwsh`, `cmd.exe`), which grants the server arbitrary command execution and makes any prompt injection catastrophic. Also detects `curl | sh`-style patterns that fetch and execute unverified remote code — a supply-chain risk that has produced multiple real-world incidents in 2025-2026.
|
|
68
|
+
|
|
69
|
+
**Severity:** `high` (shell wrappers), `critical` (curl-pipe-shell)
|
|
70
|
+
|
|
71
|
+
### `tessen-suspicious-args` — Unicode injection and overbroad scopes
|
|
72
|
+
|
|
73
|
+
Two related detections. First, invisible Unicode characters (categories Cf and Cc — zero-width spaces, bidi overrides, format characters) in server arguments, which are the primary carrier for hidden prompt injection instructions. Second, filesystem-wide access grants like `/`, `/home`, or `~` passed to filesystem MCP servers.
|
|
74
|
+
|
|
75
|
+
**Severity:** `critical` (invisible Unicode), `high` (filesystem grants)
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
**From source (currently the only path):**
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
git clone https://github.com/SlimBenTanfous1/tessen-cli.git
|
|
85
|
+
cd tessen-cli
|
|
86
|
+
uv pip install -e .
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Requires Python 3.11 or newer. Uses [uv](https://docs.astral.sh/uv/) as the package manager — install with `curl -LsSf https://astral.sh/uv/install.sh | sh`.
|
|
90
|
+
|
|
91
|
+
**PyPI distribution is planned for `v0.2`.** Once published:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pipx install tessen # coming soon
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
### Auto-detect and scan everything
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
tessen scan
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Walks the project tree upward from the current directory looking for `.mcp.json` (Claude Code) and `.cursor/mcp.json` (Cursor), then checks the OS-specific location for Claude Desktop (`~/.config/Claude/claude_desktop_config.json` on Linux, `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows). Scans every config it finds.
|
|
108
|
+
|
|
109
|
+
### Scan a specific file
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
tessen scan path/to/claude_desktop_config.json
|
|
113
|
+
tessen scan .cursor/mcp.json --client cursor
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The `--client` flag forces a specific parser when the filename doesn't match convention. Valid values: `claude_desktop`, `claude_code`, `cursor`.
|
|
117
|
+
|
|
118
|
+
### Version
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
tessen version
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## CI integration
|
|
127
|
+
|
|
128
|
+
Tessen exits non-zero when it finds issues at or above a severity threshold. This is the mechanism to keep vulnerable configs out of your `main` branch.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
tessen scan --fail-on high
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Exit codes: `0` for clean, `1` when the threshold is met, `2` for invalid flag values.
|
|
135
|
+
|
|
136
|
+
Valid `--fail-on` values: `critical`, `high`, `medium`, `low`, `never`.
|
|
137
|
+
|
|
138
|
+
**GitHub Actions example** (roughly — this pipeline hasn't been dogfooded yet):
|
|
139
|
+
|
|
140
|
+
```yaml
|
|
141
|
+
name: MCP Config Security
|
|
142
|
+
|
|
143
|
+
on:
|
|
144
|
+
pull_request:
|
|
145
|
+
paths:
|
|
146
|
+
- ".mcp.json"
|
|
147
|
+
- ".cursor/mcp.json"
|
|
148
|
+
- ".claude/settings.json"
|
|
149
|
+
|
|
150
|
+
jobs:
|
|
151
|
+
scan:
|
|
152
|
+
runs-on: ubuntu-latest
|
|
153
|
+
steps:
|
|
154
|
+
- uses: actions/checkout@v4
|
|
155
|
+
- uses: actions/setup-python@v5
|
|
156
|
+
with:
|
|
157
|
+
python-version: "3.11"
|
|
158
|
+
- name: Install tessen
|
|
159
|
+
run: |
|
|
160
|
+
pip install git+https://github.com/SlimBenTanfous1/tessen-cli.git
|
|
161
|
+
- name: Scan MCP configs
|
|
162
|
+
run: tessen scan --fail-on high
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Example output
|
|
168
|
+
|
|
169
|
+
Scanning a config with three planted issues (real GitHub PAT, bash wrapper, filesystem-wide grant):
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
tessen scanning /path/to/claude_desktop_with_secrets.json
|
|
173
|
+
|
|
174
|
+
Findings for Claude Desktop 1 critical · 2 high
|
|
175
|
+
|
|
176
|
+
╭─ ● [CRITICAL] GitHub Personal Access Token exposed in plaintext ─────╮
|
|
177
|
+
│ The env var 'GITHUB_PERSONAL_ACCESS_TOKEN' in server │
|
|
178
|
+
│ 'github_real_leak' contains a value matching a GitHub Personal │
|
|
179
|
+
│ Access Token pattern. │
|
|
180
|
+
│ │
|
|
181
|
+
│ Location: mcpServers.github_real_leak.env.GITHUB_PERSONAL_... │
|
|
182
|
+
│ Rule: tessen-secrets-in-env │
|
|
183
|
+
│ Remediation: Move the secret to a secure store (1Password, Vault, │
|
|
184
|
+
│ or OS keychain) and reference it via ${VAR_NAME}. │
|
|
185
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
186
|
+
|
|
187
|
+
Scan summary
|
|
188
|
+
Total findings: 3
|
|
189
|
+
Critical 1
|
|
190
|
+
High 2
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Supported clients
|
|
196
|
+
|
|
197
|
+
| Client | Config path | Parser |
|
|
198
|
+
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
|
199
|
+
| Claude Desktop | `~/.config/Claude/claude_desktop_config.json` (Linux)<br>`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)<br>`%APPDATA%\Claude\claude_desktop_config.json` (Windows) | `claude_desktop` |
|
|
200
|
+
| Claude Code | `<project>/.mcp.json` | `claude_code` |
|
|
201
|
+
| Cursor | `<project>/.cursor/mcp.json` | `cursor` |
|
|
202
|
+
| Windsurf | Planned for `v0.2` | — |
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
git clone https://github.com/SlimBenTanfous1/tessen-cli.git
|
|
210
|
+
cd tessen-cli
|
|
211
|
+
uv sync # install runtime + dev deps
|
|
212
|
+
source .venv/bin/activate # or use `uv run <command>`
|
|
213
|
+
|
|
214
|
+
# Run the test suite
|
|
215
|
+
pytest tests/ -v
|
|
216
|
+
|
|
217
|
+
# Run tessen against test fixtures
|
|
218
|
+
tessen scan tests/fixtures/claude_desktop_with_secrets.json
|
|
219
|
+
tessen scan tests/fixtures/claude_desktop_clean.json
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Project layout
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
tessen-cli/
|
|
226
|
+
├── src/tessen/
|
|
227
|
+
│ ├── cli.py # Typer app: scan + version + --fail-on
|
|
228
|
+
│ ├── models.py # MCPConfig, ParseResult, ClientType
|
|
229
|
+
│ ├── output/human.py # Rich severity panels
|
|
230
|
+
│ ├── parsers/
|
|
231
|
+
│ │ ├── common.py # Shared JSON+Pydantic parse logic
|
|
232
|
+
│ │ ├── claude_desktop.py
|
|
233
|
+
│ │ ├── claude_code.py # + walker for .mcp.json
|
|
234
|
+
│ │ ├── cursor.py # + walker for .cursor/mcp.json
|
|
235
|
+
│ │ └── auto.py # Multi-client auto-detection
|
|
236
|
+
│ └── rules/
|
|
237
|
+
│ ├── base.py # Rule ABC
|
|
238
|
+
│ ├── engine.py # RuleEngine with buggy-rule isolation
|
|
239
|
+
│ ├── models.py # Finding, Severity, RuleMetadata
|
|
240
|
+
│ └── builtin/ # Built-in detection rules
|
|
241
|
+
└── tests/ # 26 tests, ~0.3s runtime
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Adding a rule
|
|
245
|
+
|
|
246
|
+
1. Create `src/tessen/rules/builtin/your_rule.py`
|
|
247
|
+
2. Subclass `Rule`, define `METADATA`, implement `check(result) -> list[Finding]`
|
|
248
|
+
3. Register it in `src/tessen/rules/builtin/__init__.py` (`BUILTIN_RULES` list)
|
|
249
|
+
4. Add tests in `tests/test_rules.py`
|
|
250
|
+
|
|
251
|
+
Rules should be pure functions: no I/O, no state, no side effects. Same input → same output.
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Roadmap
|
|
256
|
+
|
|
257
|
+
**`v0.1` (current) — foundation:** parsers for 3 clients, auto-detection, 3 detection rules, Rich terminal output, `--fail-on` for CI.
|
|
258
|
+
|
|
259
|
+
**`v0.2` — coverage:**
|
|
260
|
+
|
|
261
|
+
- 4 more rules: known-vulnerable MCP servers (CVE feed), HTTP transport without auth, dangerous auto-approve flags, hook injection in `.claude/settings.json`
|
|
262
|
+
- JSON output format
|
|
263
|
+
- SARIF output for GitHub Advanced Security compatibility
|
|
264
|
+
- Windsurf parser
|
|
265
|
+
- PyPI distribution (`pipx install tessen`)
|
|
266
|
+
|
|
267
|
+
**`v0.3` — trust:**
|
|
268
|
+
|
|
269
|
+
- GitHub Action for one-line CI integration
|
|
270
|
+
- CVE feed as a subscribable JSON endpoint
|
|
271
|
+
- First round of dogfooded findings on `awesome-mcp-servers`
|
|
272
|
+
|
|
273
|
+
**Later:**
|
|
274
|
+
|
|
275
|
+
- Custom rules via YAML
|
|
276
|
+
- Baseline files (`.tessen-baseline`) to suppress known findings
|
|
277
|
+
- Runtime hooks that observe tool calls at execution time
|
|
278
|
+
|
|
279
|
+
Anything you want prioritized? Open an issue.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Why "Tessen"
|
|
284
|
+
|
|
285
|
+
A _tessen_ (鉄扇) is an iron folding fan carried by samurai as a concealed weapon into places where swords were forbidden — tea houses, temples, palaces. Ordinary-looking. Genuinely dangerous.
|
|
286
|
+
|
|
287
|
+
Same shape as a poisoned MCP server: a small config file that looks like plumbing, and behaves like an attack.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
MIT. See [LICENSE](./LICENSE).
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## About
|
|
298
|
+
|
|
299
|
+
Built by [Slim Ben Tanfous](https://github.com/SlimBenTanfous1) — cybersecurity engineer at BrightByDesign (Zero Trust infrastructure, CI/CD security), joining the ESGI Paris Master's in Cybersecurity in September 2026. Prior: CCNA, CyberOps Associate, AWS Cloud Security Foundations, OCI Foundations.
|
|
300
|
+
|
|
301
|
+
Reach out: [github.com/SlimBenTanfous1](https://github.com/SlimBenTanfous1)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
tessen/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
tessen/cli.py,sha256=twr0gG3wFMoYxNrRKsg9TK6WtU6yBwtrKY9cy8CFs9M,6232
|
|
3
|
+
tessen/models.py,sha256=O4-k0WztABu1RiBRSiYKmd9P9iqVEw90i6T7b7MpzNs,1585
|
|
4
|
+
tessen/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
tessen/output/human.py,sha256=ncwCu6A1PMQMw_TF1zDqdFTSQ605ipoiIbzycMTrnjQ,3124
|
|
6
|
+
tessen/output/json_output.py,sha256=HS63vBaJJ7G9AWVt9SUzze7vU0WRbL56XNB3wPd37GA,1749
|
|
7
|
+
tessen/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
tessen/parsers/auto.py,sha256=ZyOphBfhv84U1WngCeb0xZk47j2OCR3jPWXV2rC2BrY,2531
|
|
9
|
+
tessen/parsers/claude_code.py,sha256=OMasF9UWLTs2bUbonWxvIIwbr5-iqktUyyfFtMgT7ug,1204
|
|
10
|
+
tessen/parsers/claude_desktop.py,sha256=_hm3ysvEdCS3CcKdaf4hliLI_VjnfEj6-ZWowSHYVWo,651
|
|
11
|
+
tessen/parsers/common.py,sha256=IlZqwjNhzATP7q7oIY9Z_2DgI_Y-JISaFB6nPW_lfhA,1903
|
|
12
|
+
tessen/parsers/cursor.py,sha256=I-O13pDEmj-j-Pjrp648hQ0GVOsQWbhavv2FItn1h5Q,1036
|
|
13
|
+
tessen/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
tessen/rules/base.py,sha256=AppWQ7i9vuXupGAP4PCnYe7xUWnkvgHfMIPyke9ijd8,731
|
|
15
|
+
tessen/rules/builtin/__init__.py,sha256=p2l4ZqjsRXUMGSaaSzWM3WcsPghkGLSMV6KoaoXM4Fk,942
|
|
16
|
+
tessen/rules/builtin/auto_approve.py,sha256=Jm78ZwNcB0eVPQZCajRhqb7XK89F-_FZPv571CMgi6k,3588
|
|
17
|
+
tessen/rules/builtin/dangerous_command.py,sha256=HfkIZSLcyubOe_5I75xKTONSqMWakqjpxsZZqWlIGDA,3929
|
|
18
|
+
tessen/rules/builtin/hook_injection.py,sha256=1CyfD_7goLleLncrYb7nxyFEoPqqp0anuLmPv3gY5NU,6140
|
|
19
|
+
tessen/rules/builtin/http_no_auth.py,sha256=vQq078OoLtzt2CdqerpZsmnYlk8A0RotIPLCJm7hLfM,3662
|
|
20
|
+
tessen/rules/builtin/known_vulnerable.py,sha256=dzqofddutyP_t-gfm5_zy411tv92AYMtFOy9h72AD-c,6262
|
|
21
|
+
tessen/rules/builtin/secrets_in_env.py,sha256=4c1MGiw5_UQ_kQarM6AxcZ353iGyGWE-sWlMRRjCDDU,4731
|
|
22
|
+
tessen/rules/builtin/suspicious_args.py,sha256=zuSj4Avu4WxYA5qRPaBiStqjUSpd-xYh4wrx1drhW3E,4006
|
|
23
|
+
tessen/rules/engine.py,sha256=zX2lrkSAh_VnmG2GODZ2OiJMUMlNcHaRnQ1TaLweu04,1380
|
|
24
|
+
tessen/rules/models.py,sha256=yxFdHBz9LygFEtW_2mSVxB6XtIaVPhJ5M4_ru15PqCs,1543
|
|
25
|
+
tessen_cli-0.1.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
|
|
26
|
+
tessen_cli-0.1.0.dist-info/entry_points.txt,sha256=nzc7tn52pnaj8uy1rKQNt8jsd1MNAG2-IczLpORxukg,43
|
|
27
|
+
tessen_cli-0.1.0.dist-info/METADATA,sha256=zWCfQXBZawJdgmK4Ges1TYa59h0dBtujAeSth-lwzVk,12255
|
|
28
|
+
tessen_cli-0.1.0.dist-info/RECORD,,
|