Files
kwonta3wook 031b8ab298 feat: add Linux x86-64 MCPTrace analysis
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.
2026-10-27 13:49:51 +09:00

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