Skip to content

Commit d5e508f

Browse files
authored
Merge pull request #736 from NVIDIA/yashraj/isolate-http-mcp-git-credentials
fix(mcp): isolate Git credentials for HTTP scans
2 parents e2bf4a1 + c555547 commit d5e508f

8 files changed

Lines changed: 315 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,12 @@ claude mcp add skillspector -- skillspector mcp
476476
> - Local paths and `file://` URLs are **automatically rejected** over HTTP to
477477
> prevent unauthenticated callers from reading arbitrary host files. Only
478478
> remote Git and `.zip` URLs are accepted.
479+
>
480+
> HTTP scans accept only credential-free `https://` targets. Git scans never use
481+
> the server's Git login or Git config. If a proxy or custom CA is needed, set
482+
> `HTTPS_PROXY`, `NO_PROXY` or `GIT_SSL_CAINFO` in the server environment.
483+
> Use stdio or the CLI for private repositories. Git redirects are rejected;
484+
> use the current repository URL after a rename or transfer.
479485
480486
## Vulnerability Patterns
481487

‎src/skillspector/input_handler.py‎

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,10 @@ class InputHandler:
767767
Normalizes all inputs to a local directory path for scanning.
768768
"""
769769

770-
def __init__(self, transitive_budget: object | None = None) -> None:
770+
def __init__(
771+
self, transitive_budget: object | None = None, *, allow_git_credentials: bool = True
772+
) -> None:
773+
self._allow_git_credentials = allow_git_credentials
771774
self._temp_dir: Path | None = None
772775
self._transitive_budget = transitive_budget
773776
self.primary_file_path: str | None = None
@@ -1129,23 +1132,99 @@ def _resolve_tree_ref(self, repository_url: str, segments: list[str]) -> tuple[s
11291132
f"{repository_url} ({'/'.join(segments)})"
11301133
)
11311134

1135+
def _git_invocation(self, url: str) -> tuple[list[str], dict[str, str] | None, str]:
1136+
"""Build a Git policy for both ref discovery and cloning.
1137+
1138+
Remote MCP callers must not borrow the server's SSH keys, helpers,
1139+
authorization headers, URL rewrites, client certificates, or netrc.
1140+
Trusted local callers retain the operator's configured Git identity.
1141+
"""
1142+
if self._allow_git_credentials:
1143+
return ["git"], None, url
1144+
parsed = urlparse(url)
1145+
if parsed.scheme != "https" or parsed.username is not None or parsed.password is not None:
1146+
raise ValueError("Untrusted Git scans require an unauthenticated HTTPS URL")
1147+
if parsed.hostname in {"gitlab.com", "bitbucket.org"}:
1148+
# Browser URLs need the Git endpoint without following a redirect
1149+
# whose destination has not passed the host/IP checks.
1150+
path = parsed.path.rstrip("/")
1151+
if path and not path.endswith(".git"):
1152+
path += ".git"
1153+
url = parsed._replace(path=path).geturl()
1154+
isolated_home = self._get_temp_dir() / "git-home"
1155+
isolated_home.mkdir(mode=0o700, exist_ok=True)
1156+
env = {
1157+
key: value
1158+
for key, value in os.environ.items()
1159+
if key.upper()
1160+
in {
1161+
"PATH",
1162+
"SYSTEMROOT",
1163+
"WINDIR",
1164+
"TEMP",
1165+
"TMP",
1166+
"TMPDIR",
1167+
"HTTP_PROXY",
1168+
"HTTPS_PROXY",
1169+
"ALL_PROXY",
1170+
"NO_PROXY",
1171+
"GIT_SSL_CAINFO",
1172+
"GIT_SSL_CAPATH",
1173+
"SSL_CERT_FILE",
1174+
"SSL_CERT_DIR",
1175+
"CURL_CA_BUNDLE",
1176+
}
1177+
}
1178+
env.update(
1179+
HOME=str(isolated_home),
1180+
USERPROFILE=str(isolated_home),
1181+
XDG_CONFIG_HOME=str(isolated_home),
1182+
GIT_CONFIG_GLOBAL=os.devnull,
1183+
GIT_CONFIG_SYSTEM=os.devnull,
1184+
GIT_CONFIG_NOSYSTEM="1",
1185+
GIT_TERMINAL_PROMPT="0",
1186+
GIT_ALLOW_PROTOCOL="https",
1187+
GIT_CEILING_DIRECTORIES=str(isolated_home.parent),
1188+
)
1189+
return (
1190+
[
1191+
"git",
1192+
"-C",
1193+
str(isolated_home),
1194+
"-c",
1195+
"credential.helper=",
1196+
"-c",
1197+
"core.askPass=",
1198+
"-c",
1199+
f"core.hooksPath={os.devnull}",
1200+
"-c",
1201+
"http.extraHeader=",
1202+
"-c",
1203+
"http.followRedirects=false",
1204+
],
1205+
env,
1206+
url,
1207+
)
1208+
11321209
def _list_remote_refs(self, repository_url: str) -> set[str]:
11331210
"""Return the branch/tag names advertised by the remote repository.
11341211
11351212
Bounded by the ingest deadline; the host allowlist and private-IP
11361213
checks from URL validation apply.
11371214
"""
1215+
git_argv, git_env, repository_url = self._git_invocation(repository_url)
11381216
self._validate_url_host(repository_url, ALLOWED_GIT_HOSTS)
11391217
deadline = self._deadline()
11401218
self._check_deadline(deadline, "git")
11411219
timeout = max(1.0, deadline - monotonic())
11421220
try:
11431221
process = subprocess.run(
1144-
["git", "ls-remote", repository_url],
1222+
[*git_argv, "ls-remote", repository_url],
11451223
stdout=subprocess.PIPE,
11461224
stderr=subprocess.DEVNULL,
11471225
timeout=timeout,
11481226
check=False,
1227+
**({"env": git_env} if git_env is not None else {}),
11491228
)
11501229
except subprocess.TimeoutExpired as exc:
11511230
raise IngestLimitExceededError("Git ref listing exceeded its time limit") from exc
@@ -1206,6 +1285,7 @@ def _clone_git(self, url: str, *, branch: str | None = None) -> Path:
12061285
self._truncate("byte_budget_exhausted", "git")
12071286
if remaining_artifacts is not None and remaining_artifacts <= 0:
12081287
self._truncate("artifact_budget_exhausted", "git")
1288+
git_argv, git_env, url = self._git_invocation(url)
12091289
self._validate_url_host(url, ALLOWED_GIT_HOSTS)
12101290
deadline = self._deadline()
12111291
self._check_deadline(deadline, "git")
@@ -1225,6 +1305,7 @@ def _clone_git(self, url: str, *, branch: str | None = None) -> Path:
12251305
clone_command[6:6] = ["--branch", branch]
12261306
if remaining_bytes is not None:
12271307
clone_command.insert(6, f"--filter=blob:limit={remaining_bytes}")
1308+
clone_command[:1] = git_argv
12281309
process: subprocess.Popen[bytes] | None = None
12291310
final_measurement: _TreeMeasurement | None = None
12301311
try:
@@ -1233,6 +1314,7 @@ def _clone_git(self, url: str, *, branch: str | None = None) -> Path:
12331314
stdout=subprocess.DEVNULL,
12341315
stderr=subprocess.DEVNULL,
12351316
shell=False,
1317+
**({"env": git_env} if git_env is not None else {}),
12361318
)
12371319
while True:
12381320
self._check_deadline(deadline, "git")

‎src/skillspector/mcp_server.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from ipaddress import ip_address
3232
from pathlib import Path
3333
from typing import TYPE_CHECKING, Any
34+
from urllib.parse import urlparse
3435

3536
from skillspector import __version__
3637
from skillspector.cleanup import TempDirTracker, cleanup_result
@@ -116,7 +117,7 @@ async def run_scan(
116117
:data:`VALID_FORMATS`.
117118
allow_local_targets: Whether local filesystem targets are allowed.
118119
HTTP MCP calls set this to ``False`` so routable servers do not
119-
accept caller-controlled local paths.
120+
accept caller-controlled local paths or use ambient Git credentials.
120121
yara_rules_dir: Optional directory of additional YARA rules.
121122
122123
Returns:
@@ -133,12 +134,20 @@ async def run_scan(
133134
local_yara_rules = yara_rules_dir is not None and _is_local_target(yara_rules_dir)
134135
if local_target or local_yara_rules:
135136
raise ValueError("local targets are disabled for this MCP transport")
137+
parsed_target = urlparse(target.strip())
138+
if (
139+
parsed_target.scheme != "https"
140+
or parsed_target.username is not None
141+
or parsed_target.password is not None
142+
):
143+
raise ValueError("this MCP transport requires an unauthenticated HTTPS target")
136144

137145
llm_preflight_available, _ = is_llm_available()
138146
llm_enabled = use_llm and llm_preflight_available
139147

140148
state: dict[str, Any] = {
141149
"input_path": target,
150+
"allow_git_credentials": allow_local_targets,
142151
"output_format": output_format,
143152
"use_llm": llm_enabled,
144153
"llm_requested": use_llm,

‎src/skillspector/nodes/resolve_input.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ def resolve_input(state: SkillspectorState) -> dict[str, object]:
5656
workflow_budget = ensure_workflow_resource_budget(state)
5757

5858
if input_path and isinstance(input_path, str) and input_path.strip():
59-
handler = InputHandler(transitive_budget=workflow_budget)
59+
handler = InputHandler(
60+
transitive_budget=workflow_budget,
61+
allow_git_credentials=state.get("allow_git_credentials", True),
62+
)
6063
try:
6164
resolved, source_type = handler.resolve(input_path.strip())
6265
temp_dir = handler.temp_dir_for_cleanup()

‎src/skillspector/state.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ class SkillspectorState(TypedDict, total=False):
235235

236236
# Input: resolve_input node consumes input_path or skill_path, sets skill_path
237237
input_path: str | None
238+
# Only trusted local transports may use the operator's Git credentials.
239+
allow_git_credentials: bool
238240
skill_path: str | None
239241
# Relative identity of a wrapped/downloaded explicit file, distinct from passive assets.
240242
primary_file_path: str | None

‎tests/nodes/test_resolve_input.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ def test_workflow_budget_starts_before_input_materialization(
9191
class CapturingHandler:
9292
primary_file_path = None
9393

94-
def __init__(self, transitive_budget: object | None = None) -> None:
94+
def __init__(
95+
self, transitive_budget: object | None = None, *, allow_git_credentials: bool = True
96+
) -> None:
97+
assert allow_git_credentials is True
9598
assert transitive_budget is not None
9699
assert getattr(transitive_budget, "started_at", None) is not None
97100
captured.append(transitive_budget)
@@ -116,7 +119,10 @@ def test_transitive_truncation_is_typed_sanitized_and_cleaned(
116119
cleaned: list[bool] = []
117120

118121
class TruncatedHandler:
119-
def __init__(self, transitive_budget: object | None = None) -> None:
122+
def __init__(
123+
self, transitive_budget: object | None = None, *, allow_git_credentials: bool = True
124+
) -> None:
125+
assert allow_git_credentials is True
120126
assert transitive_budget is not None
121127

122128
def resolve(self, _input_path: str) -> tuple[Path, str]:
@@ -323,3 +329,13 @@ def wait(self, timeout: float | None = None) -> int:
323329
assert [clone.terminated for clone in InterruptedClone.instances] == [True]
324330
assert created
325331
assert [path for path in created if path.exists()] == []
332+
333+
334+
def test_resolve_input_preserves_anonymous_git_policy() -> None:
335+
with pytest.raises(ValueError, match="unauthenticated HTTPS"):
336+
resolve_input(
337+
{
338+
"input_path": "git@github.com:org/private.git",
339+
"allow_git_credentials": False,
340+
}
341+
)

0 commit comments

Comments
 (0)