mirror of
https://github.com/Kowntaewook/MCPTrace.git
synced 2026-11-02 04:45:09 +01:00
031b8ab298
Add a Linux task/VMA adapter and POSIX path policy checks. Share scanning and output with Windows while preserving its existing VAD fields. Report skipped address spaces and scan coverage, and test both adapters across host platforms.
182 lines
7.1 KiB
Python
182 lines
7.1 KiB
Python
"""Explicit analyst policy checks on complete tools/call arguments only."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ntpath
|
|
import posixpath
|
|
import re
|
|
from urllib.parse import urlsplit
|
|
|
|
from mcptrace.core import Artifact
|
|
|
|
OPERATORS = {"equals", "contains", "url_host_not_in", "windows_path_outside", "posix_path_outside"}
|
|
|
|
|
|
def validate_policy(document: object) -> list[dict]:
|
|
if (
|
|
not isinstance(document, dict)
|
|
or type(document.get("version")) is not int
|
|
or document["version"] != 1
|
|
or set(document) != {"version", "rules"}
|
|
):
|
|
raise ValueError("Policy must be an object with version 1")
|
|
rules = document.get("rules")
|
|
if not isinstance(rules, list) or len(rules) > 100:
|
|
raise ValueError("Policy rules must be an array of at most 100 entries")
|
|
seen = set()
|
|
for rule in rules:
|
|
if not isinstance(rule, dict):
|
|
raise ValueError("Each rule must be an object")
|
|
if set(rule) - {"id", "description", "severity", "tool", "argument", "operator", "value"}:
|
|
raise ValueError("Unknown rule field")
|
|
for key in ("id", "description", "tool", "argument", "operator", "severity"):
|
|
if not isinstance(rule.get(key), str) or not rule[key]:
|
|
raise ValueError(f"Rule requires a nonempty {key}")
|
|
if rule["id"] in seen:
|
|
raise ValueError("Duplicate rule ID")
|
|
seen.add(rule["id"])
|
|
if rule["severity"] not in {"low", "medium", "high"}:
|
|
raise ValueError("Severity must be low, medium, or high")
|
|
if (
|
|
not rule["argument"].startswith("/")
|
|
or re.search(r"~(?![01])", rule["argument"])
|
|
or rule["operator"] not in OPERATORS
|
|
):
|
|
raise ValueError("Invalid argument JSON pointer or operator")
|
|
value = rule.get("value")
|
|
if rule["operator"] in {"equals", "contains"}:
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError("String operators require a nonempty string value")
|
|
else:
|
|
if (
|
|
not isinstance(value, list)
|
|
or not value
|
|
or any(not isinstance(v, str) or not v for v in value)
|
|
):
|
|
raise ValueError("Allowlist operators require a nonempty string array")
|
|
if rule["operator"] == "windows_path_outside" and any(
|
|
_absolute_windows(v) is None for v in value
|
|
):
|
|
raise ValueError("Allowed Windows paths must be absolute, without variables")
|
|
if rule["operator"] == "posix_path_outside" and any(
|
|
_absolute_posix(v) is None for v in value
|
|
):
|
|
raise ValueError("Allowed POSIX paths must be absolute, without variables or globs")
|
|
if rule["operator"] == "url_host_not_in" and any(_host(v) is None for v in value):
|
|
raise ValueError("Allowed URL hosts must be host names, without ports or wildcards")
|
|
return rules
|
|
|
|
|
|
def _pointer(obj: object, pointer: str) -> object:
|
|
for token in pointer.split("/")[1:]:
|
|
token = token.replace("~1", "/").replace("~0", "~")
|
|
if isinstance(obj, dict):
|
|
obj = obj[token]
|
|
elif isinstance(obj, list) and re.fullmatch(r"0|[1-9][0-9]*", token):
|
|
obj = obj[int(token)]
|
|
else:
|
|
raise KeyError(pointer)
|
|
return obj
|
|
|
|
|
|
def _host(value: str) -> str | None:
|
|
if not value or any(c in value for c in "/:@* \\?#") or any(ord(c) < 33 for c in value):
|
|
return None
|
|
try:
|
|
return value.rstrip(".").encode("idna").decode("ascii").lower() or None
|
|
except UnicodeError:
|
|
return None
|
|
|
|
|
|
def _absolute_windows(value: str) -> str | None:
|
|
value = value.replace("/", "\\")
|
|
if value.startswith("\\\\?\\UNC\\"):
|
|
value = "\\\\" + value[8:]
|
|
elif value.startswith("\\\\?\\"):
|
|
value = value[4:]
|
|
if any(ord(c) < 32 for c in value) or "%" in value or "$" in value:
|
|
return None
|
|
if value.startswith("\\\\.\\"):
|
|
return None
|
|
drive, tail = ntpath.splitdrive(value)
|
|
if not drive or not tail.startswith("\\"):
|
|
return None
|
|
if not re.fullmatch(r"[a-zA-Z]:", drive) and not re.fullmatch(r"\\\\[^\\]+\\[^\\]+", drive):
|
|
return None
|
|
# Alternate streams, wildcards, and Win32-trimmed components need filesystem
|
|
# semantics we cannot recover here. Do not silently classify these as allowed.
|
|
if any(c in tail for c in ':*?"<>|'):
|
|
return None
|
|
if any(part not in {"", ".", ".."} and part.endswith((".", " ")) for part in tail.split("\\")):
|
|
return None
|
|
return ntpath.normcase(ntpath.normpath(value))
|
|
|
|
|
|
def _absolute_posix(value: str) -> str | None:
|
|
# Always use target POSIX semantics, including when the analysis host is Windows.
|
|
# A leading // is implementation-defined by POSIX; do not silently rewrite it.
|
|
if not value.startswith("/") or value.startswith("//"):
|
|
return None
|
|
if any(ord(c) < 32 for c in value) or any(c in value for c in "$*?["):
|
|
return None
|
|
return posixpath.normpath(value)
|
|
|
|
|
|
def _check(value: str, rule: dict) -> bool | None:
|
|
operator, expected = rule["operator"], rule["value"]
|
|
if operator == "equals":
|
|
return value == expected
|
|
if operator == "contains":
|
|
return expected in value
|
|
if operator == "url_host_not_in":
|
|
try:
|
|
if any(ord(c) < 33 for c in value):
|
|
return None
|
|
parsed = urlsplit(value)
|
|
host = _host(parsed.hostname or "")
|
|
if parsed.scheme.lower() not in {"http", "https"} or host is None:
|
|
return None
|
|
return host not in {_host(v) for v in expected}
|
|
except ValueError:
|
|
return None
|
|
normalize = _absolute_posix if operator == "posix_path_outside" else _absolute_windows
|
|
path_module = posixpath if operator == "posix_path_outside" else ntpath
|
|
path = normalize(value)
|
|
if path is None:
|
|
return None
|
|
for allowed in expected:
|
|
root = normalize(allowed)
|
|
try:
|
|
if root is not None and path_module.commonpath([path, root]) == root:
|
|
return False
|
|
except ValueError: # Different drives.
|
|
pass
|
|
return True
|
|
|
|
|
|
def evaluate(record: Artifact, rules: list[dict]) -> list[dict]:
|
|
if record.state != "complete" or record.method != "tools/call":
|
|
return []
|
|
params = (record.obj or {}).get("params")
|
|
arguments = params.get("arguments") if isinstance(params, dict) else None
|
|
findings = []
|
|
for rule in rules:
|
|
if rule["tool"] != record.tool:
|
|
continue
|
|
try:
|
|
value = _pointer(arguments, rule["argument"])
|
|
matched = _check(value, rule) if isinstance(value, str) else None
|
|
except (KeyError, IndexError):
|
|
matched = None
|
|
if matched is not False:
|
|
findings.append(
|
|
{
|
|
"rule_id": rule["id"],
|
|
"severity": rule["severity"],
|
|
"status": "policy-match" if matched else "not-evaluated",
|
|
"argument": rule["argument"],
|
|
"description": rule["description"],
|
|
}
|
|
)
|
|
return findings
|