Skip to content

Commit a922cb2

Browse files
author
Johnson George
committed
mcp: address PR #4508 review feedback
- log_analysis: exact-match portal hostname (CodeQL); safer tar/zip extraction; honor SAS URL instead of DefaultAzureCredential. - server.py: FORWARDED_ALLOW_IPS env var (default 127.0.0.1). - Dockerfile: run as non-root mcp user; add .dockerignore. - docs_index.yaml: remove duplicate keys. - tests: reconcile tool count to 25; fix trivial assertions; --xml help.
1 parent fb344a7 commit a922cb2

8 files changed

Lines changed: 78 additions & 53 deletions

File tree

mcp/.dockerignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Build / packaging artifacts
2+
**/__pycache__
3+
**/*.pyc
4+
**/*.pyo
5+
**/*.egg-info
6+
build/
7+
dist/
8+
9+
# Virtualenvs and editor state
10+
.venv/
11+
venv/
12+
.env
13+
.env.*
14+
.vscode/
15+
.idea/
16+
17+
# Internal / local-only scripts and scratch files (gitignored)
18+
tmp/
19+
20+
# Tests are still copied (used by smoke tests in CI), but exclude fixtures
21+
# generated locally:
22+
tests/__pycache__/

mcp/Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ RUN pip install --no-cache-dir "/app/lisa/mcp[azure]"
2020
# Point the MCP server at the cloned repo
2121
ENV LISA_REPO_ROOT=/app/lisa
2222

23+
# Drop privileges: run as a non-root user. /app must remain readable but
24+
# writable areas (logs, downloads) are scoped to /home/mcp at runtime.
25+
RUN useradd --create-home --shell /usr/sbin/nologin --uid 10001 mcp \
26+
&& chown -R mcp:mcp /app
27+
USER mcp
28+
2329
EXPOSE 8080
2430

2531
ENTRYPOINT ["lisa-mcp"]

mcp/lisa_mcp/docs_index.yaml

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -90,37 +90,6 @@ tools:
9090
- docs/write_test/write_case.rst
9191

9292
# ── knowledge.py ───────────────────────────────────────────────
93-
lisa_explain_concept:
94-
primary: docs/write_test/concepts.rst
95-
supplementary: []
96-
97-
lisa_get_api_reference:
98-
primary: docs/write_test/write_case.rst
99-
supplementary: []
100-
101-
lisa_find_examples:
102-
primary: docs/write_test/write_case.rst
103-
supplementary: []
104-
105-
lisa_list_tools:
106-
primary: docs/write_test/write_case.rst
107-
supplementary: []
108-
109-
lisa_list_features:
110-
primary: docs/write_test/write_case.rst
111-
supplementary: []
112-
113-
lisa_explain_error:
114-
primary: docs/run_test/troubleshoot_failures.rst
115-
supplementary: []
116-
117-
# ── execution.py ───────────────────────────────────────────────
118-
lisa_run:
119-
primary: docs/run_test/run.rst
120-
supplementary:
121-
- docs/run_test/command_line.rst
122-
123-
# ── Framework Knowledge ────────────────────────────────────────
12493
lisa_explain_concept:
12594
primary: docs/write_test/concepts.rst
12695
supplementary:
@@ -146,6 +115,16 @@ tools:
146115
primary: docs/write_test/extension.rst
147116
supplementary: []
148117

118+
lisa_explain_error:
119+
primary: docs/run_test/troubleshoot_failures.rst
120+
supplementary: []
121+
122+
# ── execution.py ───────────────────────────────────────────────
123+
lisa_run:
124+
primary: docs/run_test/run.rst
125+
supplementary:
126+
- docs/run_test/command_line.rst
127+
149128

150129
# ── Topic index (used by explain_concept for targeted lookup) ────
151130
#

mcp/lisa_mcp/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,18 @@ async def handle_sse(request):
126126
],
127127
)
128128

129+
# Restrict which proxy IPs may set X-Forwarded-* headers. Set
130+
# FORWARDED_ALLOW_IPS to your reverse proxy's IP(s) in deployment.
131+
# Defaults to loopback to prevent client-side spoofing of the
132+
# forwarded client IP.
133+
forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")
134+
129135
uvicorn.run(
130136
app,
131137
host=args.host,
132138
port=args.port,
133139
log_level="info",
134-
forwarded_allow_ips="*",
140+
forwarded_allow_ips=forwarded_allow_ips,
135141
proxy_headers=True,
136142
)
137143
else:

mcp/lisa_mcp/tools/log_analysis.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -425,9 +425,13 @@ def lisa_download_logs(
425425
is_azure_blob = parsed.hostname and parsed.hostname.endswith(
426426
".blob.core.windows.net"
427427
)
428+
# If the URL already carries a SAS token, the URL itself is the
429+
# credential — skip the Azure SDK / DefaultAzureCredential path
430+
# and let the plain HTTPS download below use the SAS URL directly.
431+
has_sas = "sig=" in (parsed.query or "")
428432

429433
# Azure blob prefix (virtual directory) — list + download all
430-
if is_azure_blob and not auth_token:
434+
if is_azure_blob and not auth_token and not has_sas:
431435
path_parts = [p for p in parsed.path.strip("/").split("/") if p]
432436
if len(path_parts) >= 2:
433437
container = path_parts[0]
@@ -1272,7 +1276,8 @@ def _parse_portal_storage_url(url: str) -> Optional[dict[str, str]]:
12721276
Returns ``None`` if the URL is not a portal storage URL.
12731277
"""
12741278
parsed = urlparse(url)
1275-
if not parsed.hostname or not parsed.hostname.endswith("portal.azure.com"):
1279+
host = (parsed.hostname or "").rstrip(".").lower()
1280+
if host != "portal.azure.com":
12761281
return None
12771282
if not parsed.fragment:
12781283
return None
@@ -1397,25 +1402,32 @@ def _extract_archive(download_path: str, download_dir: str) -> str:
13971402

13981403
if tarfile.is_tarfile(download_path):
13991404
os.makedirs(extract_dir, exist_ok=True)
1405+
abs_extract = os.path.abspath(extract_dir)
14001406
with tarfile.open(download_path) as tf:
1401-
safe_members = [
1402-
m
1403-
for m in tf.getmembers()
1404-
if not m.name.startswith(("/", "..")) and ".." not in m.name
1405-
]
1406-
tf.extractall(extract_dir, members=safe_members)
1407+
safe_members = []
1408+
for m in tf.getmembers():
1409+
target = os.path.abspath(os.path.join(abs_extract, m.name))
1410+
if os.path.commonpath([abs_extract, target]) != abs_extract:
1411+
continue
1412+
safe_members.append(m)
1413+
# filter="data" (PEP 706) blocks unsafe members (links, abs paths,
1414+
# device files) on Python 3.12+; older versions ignore the kwarg
1415+
# via the try/except.
1416+
try:
1417+
tf.extractall(extract_dir, members=safe_members, filter="data")
1418+
except TypeError:
1419+
tf.extractall(extract_dir, members=safe_members)
14071420
os.remove(download_path)
14081421
return extract_dir
14091422

14101423
if zipfile.is_zipfile(download_path):
14111424
os.makedirs(extract_dir, exist_ok=True)
1425+
abs_extract = os.path.abspath(extract_dir)
14121426
with zipfile.ZipFile(download_path) as zf:
1413-
safe_names = [
1414-
n
1415-
for n in zf.namelist()
1416-
if not n.startswith(("/", "..")) and ".." not in n
1417-
]
1418-
for name in safe_names:
1427+
for name in zf.namelist():
1428+
target = os.path.abspath(os.path.join(abs_extract, name))
1429+
if os.path.commonpath([abs_extract, target]) != abs_extract:
1430+
continue
14191431
zf.extract(name, extract_dir)
14201432
os.remove(download_path)
14211433
return extract_dir

mcp/run_tests.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ def main() -> int:
7070
group.add_argument(
7171
"--smoke",
7272
action="store_true",
73-
help="Quick smoke test — verify all 24 tools are registered",
73+
help="Quick smoke test — verify all 25 tools are registered",
7474
)
7575
parser.add_argument(
7676
"--xml",
7777
action="store_true",
78-
help="Output JUnit XML report to test-results.xml",
78+
help="Output JUnit XML reports into the test-results/ directory",
7979
)
8080
parser.add_argument(
8181
"-v",

mcp/tests/test_all_tools.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
"""Comprehensive functional tests for all 24 MCP tools.
4+
"""Comprehensive functional tests for all 25 MCP tools.
55
66
Run from the mcp/ directory:
77
python -m pytest tests/test_all_tools.py -v
@@ -295,8 +295,8 @@ def test_log_from_content(self) -> None:
295295
"lisa_analyze_log",
296296
log_content="smoke_test | PASSED | ok\nverify_x | FAILED | boom\n",
297297
)
298-
self.assertIn("1", result) # 1 passed
299-
self.assertIn("1", result) # 1 failed
298+
self.assertIn("passed", result.lower())
299+
self.assertIn("1 failed", result.lower())
300300

301301
def test_empty_log(self) -> None:
302302
result = _call("lisa_analyze_log", log_content="nothing relevant here\n")
@@ -726,7 +726,7 @@ def test_returns_features(self) -> None:
726726

727727

728728
# ======================================================================
729-
# Cross-cutting: verify all 24 tools are registered
729+
# Cross-cutting: verify all 25 tools are registered
730730
# ======================================================================
731731

732732

mcp/tests/test_mcp_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ async def _list_tools(self) -> list:
8080
def test_server_starts_and_lists_tools(self) -> None:
8181
tools = _run(self._list_tools())
8282
names = {t.name for t in tools}
83-
self.assertEqual(len(names), 24, f"Expected 24 tools, got {len(names)}")
83+
self.assertEqual(len(names), 25, f"Expected 25 tools, got {len(names)}")
8484
self.assertIn("lisa_analyze_log", names)
8585
self.assertIn("lisa_write_test", names)
8686
self.assertIn("lisa_explain_concept", names)

0 commit comments

Comments
 (0)