mirror of
https://github.com/Kowntaewook/MCPTrace.git
synced 2026-11-02 04:45:09 +01:00
Merge pull request #1 from Kowntaewook/feat/windows-mcptrace
feat: Windows MCPTrace Volatility 3 플러그인 구현
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python: ['3.10', '3.12']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- run: python -m pip install -e ".[dev]"
|
||||
- run: python -m ruff check .
|
||||
- run: python -m ruff format --check .
|
||||
- run: python -m pytest -q
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
*.raw
|
||||
*.vmem
|
||||
*.dmp
|
||||
*.mem
|
||||
evidence/
|
||||
output/
|
||||
@@ -1 +1,200 @@
|
||||
# MCPTrace
|
||||
# MCPTrace
|
||||
|
||||
Windows 프로세스 메모리에서 MCP/JSON-RPC 메시지 후보를 추출하는 **Volatility 3 플러그인**.
|
||||
수집된 메모리 이미지를 분석하고, 메시지 원문·주소·도구 인자·정책 검사 결과를 남깁니다.
|
||||
|
||||
**상태: 초기 구현.** 프레임워크 패키지 버전은 `volatility3==2.28.2`로 고정했습니다.
|
||||
Windows x64를 대상으로 하며 첫 실증 대상은
|
||||
Windows 11 x64입니다. 현재 검증은 합성 데이터, 실제 Volatility 메모리 레이어·출력기,
|
||||
CLI 플러그인 로딩까지입니다. 실제 Windows RAM 이미지에서의 복원율과 호환성은 아직 검증하지 않았습니다.
|
||||
|
||||
## 기능
|
||||
|
||||
- `windows.mcptrace.MCPTrace`: `pslist` 프로세스의 VAD를 읽어 UTF-8·UTF-16LE JSON 후보 추출.
|
||||
- 문자열 안의 중괄호, 이스케이프, 중첩 객체, 청크 경계 처리. 누락된 페이지와 VAD 경계는 연결하지 않음.
|
||||
- MCP 메서드, `tools/call` 도구명과 인자, 응답의 `result`/`error` 보존.
|
||||
- PID·VAD 시작 주소·프로세스 가상 주소·인코딩·원문 SHA-256 표시.
|
||||
- 동일 PID와 **타입까지 같은 요청 ID**를 기준으로 요청·응답 후보 제시. ID 충돌은 모호함으로 표시.
|
||||
- 선택적으로 사용자 정책에 맞는 호출을 표시하고, 원문 바이트를 포함한 JSONL 내보내기.
|
||||
- 프로세스마다 읽은 바이트, 누락된 바이트, 검색 제한, 오류를 요약 행으로 표시.
|
||||
|
||||
메모리에서 JSON이 발견됐다는 사실만으로 실제 전송, 도구 실행, 공격 성공을 입증할 수는 없습니다.
|
||||
로그·캐시·예제 문자열도 같은 형태로 남을 수 있습니다. `complete`는 아래 파서가 완전한 JSON-RPC
|
||||
형태로 읽었다는 뜻이며, MCP 전체 스키마 검증이나 실제 통신 여부 검증을 의미하지 않습니다.
|
||||
|
||||
## 설치
|
||||
|
||||
Python 3.10 이상과 이 저장소의 체크아웃이 필요합니다. 저장소 루트에서 실행합니다.
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
py -3.12 -m venv .venv
|
||||
.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
|
||||
.\.venv\Scripts\vol.exe -p .\plugins windows.mcptrace.MCPTrace -h
|
||||
```
|
||||
|
||||
Linux 분석 호스트에서도 Windows 이미지를 대상으로 실행할 수 있습니다.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install -e '.[dev]'
|
||||
.venv/bin/vol -p ./plugins windows.mcptrace.MCPTrace -h
|
||||
```
|
||||
|
||||
`mcptrace/`는 설치할 Python 패키지이고 `plugins/`는 `-p`로 지정할 Volatility 플러그인 경로입니다.
|
||||
두 경로를 함께 유지해야 합니다. 개발 도구가 불필요하면 `pip install -e .`로 설치합니다.
|
||||
아래 예시는 가상환경의 `vol`이 PATH에 있다는 전제입니다. PowerShell에서는 위의 `vol.exe` 전체 경로를
|
||||
사용해도 됩니다. 플러그인 폴더를 옮긴 경우 `-p`에 절대 경로를 지정합니다.
|
||||
|
||||
## 실행
|
||||
|
||||
먼저 Volatility가 이미지와 Windows 심볼을 정상적으로 해석하는지 확인합니다.
|
||||
|
||||
```powershell
|
||||
vol -f C:\Evidence\memory.raw windows.info.Info
|
||||
vol -f C:\Evidence\memory.raw windows.pslist.PsList
|
||||
```
|
||||
|
||||
MCP 클라이언트와 서버는 서로 다른 프로세스에 있을 수 있으므로 해당 PID들을 함께 확인합니다.
|
||||
아래 PID는 예시입니다. PID를 생략하면 `pslist`가 열거한 모든 프로세스를 시도합니다.
|
||||
|
||||
```powershell
|
||||
vol -p .\plugins -f C:\Evidence\memory.raw windows.mcptrace.MCPTrace --pid 4242 5252
|
||||
```
|
||||
|
||||
정책 적용 및 JSONL 저장:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force .\output | Out-Null
|
||||
vol -p .\plugins -f C:\Evidence\memory.raw -o .\output windows.mcptrace.MCPTrace --pid 4242 --policy .\examples\policy.json --dump
|
||||
```
|
||||
|
||||
전체 TreeGrid를 JSON으로 저장할 때는 전역 렌더러 옵션을 플러그인 이름 앞에 둡니다.
|
||||
|
||||
```powershell
|
||||
vol -p .\plugins -f C:\Evidence\memory.raw -r json windows.mcptrace.MCPTrace --pid 4242 > findings.json
|
||||
```
|
||||
|
||||
Volatility는 심볼 확보를 위해 네트워크를 사용할 수 있습니다. 사전에 필요한 심볼·캐시를 준비한 환경에서는
|
||||
전역 옵션 `--offline`을 사용할 수 있습니다. MCPTrace 자체는 외부 API나 LLM을 호출하지 않습니다.
|
||||
|
||||
## 옵션
|
||||
|
||||
| 옵션 | 기본값 | 의미 |
|
||||
| --- | --- | --- |
|
||||
| `--pid 4242 5252` | 전체 | 지정한 PID만 분석 |
|
||||
| `--include-partial` | 끔 | 파싱되지 않은 JSON-RPC 앵커 주변 조각을 별도 표시 |
|
||||
| `--include-rpc` | 끔 | MCP 관련성이 확인되지 않은 일반 JSON-RPC 후보도 표시 |
|
||||
| `--max-object-bytes` | 65536 | 완전한 JSON 하나의 최대 바이트 크기, 256–4194304 |
|
||||
| `--max-records` | 10000 | 프로세스당 후보 수 제한, 필터 적용 **전** 기준 |
|
||||
| `--max-bytes` | 0 | 프로세스당 읽기를 시도할 VAD 범위 바이트 예산, 0은 제한 없음 |
|
||||
| `--policy` | 없음 | 로컬 정책 JSON 파일 |
|
||||
| `--suspicious-only` | 끔 | `policy-match`인 후보와 요약만 표시, 비어 있지 않은 정책 필요 |
|
||||
| `--dump` | 끔 | 표시되는 후보를 `-o` 디렉터리에 JSONL로 내보내기 |
|
||||
|
||||
`--include-rpc`가 없으면 알려진 MCP 메서드, MCP 형태의 응답, 해당 요청과 같은 PID·ID의 응답을
|
||||
표시합니다. 메서드 목록은 `mcptrace/core.py`의 `MCP_METHODS`에서 관리합니다.
|
||||
MCP 메서드명과 응답 형태는 다른 프로토콜과 겹칠 수 있으므로 이 필터는 관련성 휴리스틱입니다.
|
||||
|
||||
## 결과 해석
|
||||
|
||||
| 필드 / 값 | 의미 |
|
||||
| --- | --- |
|
||||
| `VirtualAddress` | 해당 PID의 프로세스 가상 주소. 이미지 파일 오프셋이 아님 |
|
||||
| `complete` | 중복 키·잘못된 인코딩 등을 거부하는 파서가 읽은 JSON-RPC 형태의 객체 |
|
||||
| `fragment` | JSON-RPC 앵커부터 최대 512바이트의 원문 조각. ID·인자·응답을 추정하지 않음 |
|
||||
| `candidate-only` | 같은 PID·타입별 ID의 상대 메시지가 있음. 실제 세션 연결은 미확정 |
|
||||
| `ambiguous-id` | 같은 PID·ID에 서로 다른 요청 또는 응답 텍스트가 남음 |
|
||||
| `unpaired` | 비교 대상 요청/응답이 발견되지 않음 |
|
||||
| `not-applicable` | ID가 없거나 파싱하지 못해 연결을 시도하지 않음 |
|
||||
| `CandidateAddresses` | 상대 후보 주소 목록·전체 주소 수·목록 생략 여부. 목록은 최대 256개 |
|
||||
| `policy-match` | 사용자가 정의한 조건과 인자가 일치함. 악성 판정이 아님 |
|
||||
| `not-evaluated` | 정책 대상 도구이지만 인자가 없거나 지원하지 않는 형식이라 검사 불가 |
|
||||
| `scan-summary` | 검사 범위, 제한, 오류. `Raw` 열에 JSON으로 표시 |
|
||||
|
||||
요청 ID `1`과 `"1"`은 구분하며 서로 다른 PID의 메시지는 연결하지 않습니다.
|
||||
같은 JSON 텍스트가 여러 주소·인코딩으로 남은 경우 각각의 증거를 유지합니다.
|
||||
서로 다른 공백이나 멤버 순서도 보수적으로 다른 텍스트로 취급합니다.
|
||||
메모리 주소의 크기로 시간 순서를 추정하지 않으며 세션과 통신 방향은 복원하지 않습니다.
|
||||
`RequestID`의 JSON `null`은 ID 없음과 명시적 null을 모두 나타낼 수 있으므로 원문을 확인합니다.
|
||||
|
||||
`--dump` 출력은 프로세스별 `mcptrace.pid-4242.jsonl` 파일입니다. 이름이 이미 존재하면 Volatility가
|
||||
접미사를 추가하며 요약의 `export`에 실제 파일명을 기록합니다.
|
||||
첫 줄은 메타데이터, 중간 줄은 아티팩트, 마지막 줄은 검사 요약입니다.
|
||||
아티팩트의 `raw_base64`는 **추출된 범위의 원문 바이트**, `raw_sha256`은 그 바이트의 해시입니다.
|
||||
전체 메모리 이미지의 해시가 아닙니다. `message`는 해석된 JSON이고 숫자는 Python의 정수·실수 표현을
|
||||
사용하므로 원문 검증에는 `raw_base64`를 사용합니다. 터미널의 `Raw`는 제어 문자를 이스케이프해 표시합니다.
|
||||
|
||||
## 정책
|
||||
|
||||
`examples/policy.json`은 예시 도구 `read_file`, `fetch`의 허용 경로·호스트를 검사합니다.
|
||||
사용 중인 MCP 서버의 **정확한 도구 이름과 인자 구조**에 맞게 수정해야 합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"rules": [{
|
||||
"id": "outside-case-directory",
|
||||
"description": "사건 분석 디렉터리 밖의 파일 접근 요청",
|
||||
"severity": "medium",
|
||||
"tool": "read_file",
|
||||
"argument": "/path",
|
||||
"operator": "windows_path_outside",
|
||||
"value": ["C:\\Cases"]
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
`argument`는 `params.arguments`에 대한 JSON Pointer이며 `~0`, `~1`, 배열 인덱스를 지원합니다.
|
||||
완전하게 파싱된 `tools/call`의 문자열 인자에만 적용합니다.
|
||||
|
||||
| 연산자 | 비교 |
|
||||
| --- | --- |
|
||||
| `equals` | 문자열 전체 일치, 대소문자 구분 |
|
||||
| `contains` | 문자열 포함, 대소문자 구분 |
|
||||
| `url_host_not_in` | HTTP(S) URL의 파싱된 호스트가 허용 목록 밖인지 비교 |
|
||||
| `windows_path_outside` | 절대 Windows 경로를 정규화한 뒤 허용 디렉터리 경계 밖인지 비교 |
|
||||
|
||||
URL 검사는 사용자 정보·경로의 문자열을 호스트로 오인하지 않으며 와일드카드나 암묵적 서브도메인 허용은
|
||||
없습니다. 포트는 검사 대상이 아니며 IPv6 리터럴은 현재 지원하지 않습니다. DNS·리다이렉트를 조회하지 않습니다.
|
||||
경로 검사는 문자열 기준이며 정션, 심볼릭 링크, 8.3 별칭의 실제 대상은 해석하지 않습니다.
|
||||
환경 변수, 상대 경로, ADS, 와일드카드 등 지원하지 않는 입력은 `not-evaluated`로 남깁니다.
|
||||
규칙에서 경고가 발생하지 않았다고 안전하거나 실행되지 않았다는 뜻은 아닙니다.
|
||||
|
||||
## 제한과 검증
|
||||
|
||||
- 평문 UTF-8·UTF-16LE JSON을 대상으로 합니다. 압축·암호화·바이너리 객체·V8/Python 힙 객체 재조립은 미지원입니다.
|
||||
- 리터럴 `"jsonrpc": "2.0"` 앵커를 사용합니다. 키/버전 문자가 `\u` 이스케이프로 표현되면 놓칠 수 있습니다.
|
||||
- 깊이 128, 앵커당 직전 객체 시작 후보 64개, 인코딩별 버퍼당 앵커 20000개의 제한이 있습니다.
|
||||
후보·앵커 제한에 도달하면 요약 카운터에 표시합니다. 한 메시지의 크기 제한은 바이트 단위입니다.
|
||||
- 빠진 바이트를 0으로 채우거나 다른 VAD와 이어 붙이지 않습니다. 프로세스 읽기 오류가 나면 이미 추출한
|
||||
결과를 유지하고 해당 프로세스의 나머지 검사를 중단합니다.
|
||||
- `pslist`에서 보이지 않는 프로세스, 해제되어 VAD 밖에 남은 흔적, 현재 레이어가 읽지 못하는 페이지는 범위 밖입니다.
|
||||
- `bytes_read`는 읽기에 성공한 바이트 수입니다. 후보 제한으로 중단하면 읽은 버퍼 전체가 처리됐다는 뜻은 아닙니다.
|
||||
후보 제한은 일반 JSON-RPC에도 적용되므로 잡음이 많으면 관련 메시지에 도달하기 전에 중단할 수 있습니다.
|
||||
- 검색 결과 없음은 흔적 부재의 증명이 아닙니다. 모든 프로세스에 요약 행을 남기며 탐지율·복원율 수치를 주장하지 않습니다.
|
||||
|
||||
테스트 실행:
|
||||
|
||||
```powershell
|
||||
python -m pytest -q
|
||||
python -m ruff check .
|
||||
python -m ruff format --check .
|
||||
```
|
||||
|
||||
검증 구성과 실제 Windows 이미지에서 확인해야 할 항목은 [docs/validation.md](docs/validation.md)에 있습니다.
|
||||
|
||||
## 구조
|
||||
|
||||
```text
|
||||
mcptrace/core.py JSON 추출·증거 모델·ID 후보 연결
|
||||
mcptrace/memory.py 누락 페이지를 보존하는 레이어 읽기
|
||||
mcptrace/policy.py 사용자 정의 도구 인자 검사
|
||||
plugins/windows/mcptrace.py Volatility Windows 어댑터·TreeGrid·JSONL
|
||||
tests/ 합성 증거와 실제 Volatility API 테스트
|
||||
```
|
||||
|
||||
참고 인터페이스: [Volatility 플러그인 문서](https://volatility3.readthedocs.io/en/stable/simple-plugin.html),
|
||||
[MCP 2025-11-25 기본 프로토콜](https://modelcontextprotocol.io/specification/2025-11-25/basic),
|
||||
[MCP 도구 호출](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 검증 범위
|
||||
|
||||
이 문서는 실제 Windows 호환성 검증을 수행했다는 보고서가 아닙니다.
|
||||
현재 테스트는 알려진 바이트를 메모리 레이어에 넣어 추출 결과와 비교합니다.
|
||||
|
||||
## 자동 검증
|
||||
|
||||
로컬 검증 환경: Linux, Python 3.12, Volatility 3 패키지 2.28.2.
|
||||
CI는 Ubuntu/Windows 분석 호스트와 Python 3.10/3.12 조합을 구성합니다.
|
||||
CI에서 Windows 호스트를 사용하더라도 테스트 데이터는 합성 버퍼입니다.
|
||||
|
||||
| 영역 | 검증 내용 |
|
||||
| --- | --- |
|
||||
| 원문 보존 | UTF-8/UTF-16LE, 한글·이모지, 실제 주소·원문·SHA-256 일치 |
|
||||
| 파싱 | 문자열 내부 중괄호·이스케이프·중첩, 뒤에 배치된 jsonrpc 멤버, 모든 청크 분할 위치 |
|
||||
| 손상 | 중복 키, 잘못된 인코딩, 비정상 JSON-RPC ID, 비유한 숫자 거부; 조각에서 필드 추정 금지 |
|
||||
| 메모리 경계 | 실제 BufferDataLayer 위의 누락 페이지 시뮬레이션, 인접 VAD 비결합 |
|
||||
| 후보 연결 | PID와 숫자/문자열 ID 분리, 복사본 보존, ID 충돌, 부동소수점 반올림으로 인한 오병합 방지 |
|
||||
| 정책 | 디렉터리 경계, URL 사용자 정보·가짜 접미사, JSON Pointer, 미지원 입력·잘못된 규칙 |
|
||||
| 어댑터 | 실제 TreeGrid, 프로세스별 제한·요약, 뒤쪽 VAD 읽기 오류 이후 기존 증거 보존 |
|
||||
| 출력 | 실제 Volatility CLI 파일 핸들러, JSONL 원문 왕복·해시, 기존 파일 비덮어쓰기 |
|
||||
| 로딩 | 새 CLI 프로세스에서 -p 경로로 플러그인 검색 및 도움말 생성 |
|
||||
|
||||
`tests/test_plugin.py`의 EPROCESS/VAD 열거 객체와 커널 모듈은 테스트용 대역입니다.
|
||||
Volatility의 Context, BufferDataLayer, TreeGrid, CLI 파일 핸들러는 실제 구현을 사용합니다.
|
||||
따라서 CLI 검색·어댑터 출력까지는 검증하지만 커널 심볼 해석, 실제 페이지 테이블 변환, OS 빌드 호환성은
|
||||
이 테스트만으로 입증할 수 없습니다.
|
||||
|
||||
## 실제 Windows 11 x64 이미지 확인 절차
|
||||
|
||||
1. 격리된 테스트 VM에서 OS 빌드, MCP 클라이언트·서버 버전, 전송 방식, 프로세스 PID를 기록합니다.
|
||||
2. 정확한 요청·응답 JSON과 ID를 별도 기준 자료로 저장합니다. 정상 호출, 정책에 해당하는 호출,
|
||||
같은 ID의 반복 사용, 문자열 ID, 긴 응답을 각각 포함합니다.
|
||||
3. 메모리를 수집하고 수집 도구·버전·시각·이미지 SHA-256, 호출부터 수집까지의 경과 시간을 기록합니다.
|
||||
4. 같은 이미지에서 `windows.info.Info`와 `windows.pslist.PsList`가 정상 동작하는지 확인합니다.
|
||||
5. 해당 PID에 대해 MCPTrace를 실행합니다. 처음에는 `--suspicious-only` 없이 전체 후보와 요약을 저장합니다.
|
||||
6. 내보낸 `raw_base64`를 디코딩하고 SHA-256을 확인합니다. 해당 프로세스 레이어의 가상 주소와 길이로
|
||||
다시 읽은 원문과 일치하는지 확인합니다. 가상 주소를 이미지 파일 오프셋으로 사용하지 않습니다.
|
||||
7. 기준 자료와 비교해 누락·잘못된 추출·복사본·연결 모호함을 구분합니다. 예제/로그에 의도적으로 남긴
|
||||
JSON과 일반 JSON-RPC 트래픽도 넣어 실제 통신 증거와 혼동되는 범위를 확인합니다.
|
||||
8. 기본 제한과 조정한 제한을 비교하고, 도달한 제한·누락 페이지·오류가 요약에 남는지 확인합니다.
|
||||
|
||||
재현 자료에는 명령 전체, MCPTrace 커밋, Python/Volatility 버전, OS 빌드, 정책 파일 및 그 해시,
|
||||
입력 이미지 해시, 표준 오류 로그, JSONL을 포함합니다. 실제 증거·메모리 덤프는 저장소에 커밋하지 않습니다.
|
||||
실제 이미지 결과가 확보되기 전에는 특정 클라이언트 지원, 정밀도·재현율, 세션 복원 성공률을 표시하지 않습니다.
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 1,
|
||||
"rules": [
|
||||
{
|
||||
"id": "FILE-OUTSIDE-CASE",
|
||||
"description": "read_file 요청의 경로가 분석자가 지정한 사건 폴더 밖에 있음",
|
||||
"severity": "medium",
|
||||
"tool": "read_file",
|
||||
"argument": "/path",
|
||||
"operator": "windows_path_outside",
|
||||
"value": ["C:\\Cases"]
|
||||
},
|
||||
{
|
||||
"id": "URL-OUTSIDE-ALLOWLIST",
|
||||
"description": "fetch 요청의 URL 호스트가 분석자의 허용목록에 없음",
|
||||
"severity": "medium",
|
||||
"tool": "fetch",
|
||||
"argument": "/url",
|
||||
"operator": "url_host_not_in",
|
||||
"value": ["api.example.test"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
"""MCPTrace parsing core; the Volatility adapter is in plugins/windows."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Bounded carving of literal JSON-RPC artifacts, never a proof of transmission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import bisect
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
MCP_METHODS = frozenset(
|
||||
"initialize tools/list tools/call resources/list resources/read resources/subscribe "
|
||||
"resources/unsubscribe resources/templates/list prompts/list prompts/get "
|
||||
"completion/complete logging/setLevel sampling/createMessage roots/list elicitation/create "
|
||||
"tasks/get tasks/result tasks/list tasks/cancel notifications/initialized "
|
||||
"notifications/cancelled notifications/progress notifications/message "
|
||||
"notifications/tools/list_changed notifications/resources/list_changed "
|
||||
"notifications/resources/updated notifications/prompts/list_changed "
|
||||
"notifications/roots/list_changed notifications/elicitation/complete "
|
||||
"notifications/tasks/status".split()
|
||||
)
|
||||
|
||||
|
||||
def _patterns(encoding: str) -> tuple[re.Pattern, re.Pattern]:
|
||||
def literal(text: str) -> bytes:
|
||||
return re.escape(text.encode(encoding))
|
||||
|
||||
space = rb"[ \t\r\n]*" if encoding == "utf-8" else rb"(?:[ \t\r\n]\x00)*"
|
||||
anchor = literal('"jsonrpc"') + space + literal(":") + space + literal('"2.0"')
|
||||
start = literal("{") + space + literal('"')
|
||||
return re.compile(anchor), re.compile(start)
|
||||
|
||||
|
||||
PATTERNS = {encoding: _patterns(encoding) for encoding in ("utf-8", "utf-16-le")}
|
||||
|
||||
|
||||
def _unique_object(pairs: list[tuple[str, Any]]) -> dict:
|
||||
result: dict = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("Duplicate JSON member")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _reject_constant(value: str) -> None:
|
||||
raise ValueError(f"Non-JSON number: {value}")
|
||||
|
||||
|
||||
def strict_loads(text: str) -> Any:
|
||||
def finite_float(value: str) -> float:
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError("JSON number is outside the supported finite float range")
|
||||
return number
|
||||
|
||||
return json.loads(
|
||||
text,
|
||||
object_pairs_hook=_unique_object,
|
||||
parse_constant=_reject_constant,
|
||||
parse_float=finite_float,
|
||||
)
|
||||
|
||||
|
||||
def _valid_id(value: Any) -> bool:
|
||||
# bool is an int subclass, but not a JSON-RPC request identifier.
|
||||
return type(value) in (str, int)
|
||||
|
||||
|
||||
def classify(obj: Any) -> tuple[str, bool] | None:
|
||||
if not isinstance(obj, dict) or obj.get("jsonrpc") != "2.0":
|
||||
return None
|
||||
if "method" in obj:
|
||||
if not isinstance(obj["method"], str) or "result" in obj or "error" in obj:
|
||||
return None
|
||||
if "params" in obj and not isinstance(obj["params"], (dict, list)):
|
||||
return None
|
||||
if "id" in obj and not _valid_id(obj["id"]):
|
||||
return None
|
||||
kind = "request" if "id" in obj else "notification"
|
||||
return kind, obj["method"] in MCP_METHODS
|
||||
if ("result" in obj) == ("error" in obj):
|
||||
return None
|
||||
if "id" not in obj or not (_valid_id(obj["id"]) or obj["id"] is None):
|
||||
return None
|
||||
if "error" in obj:
|
||||
error = obj["error"]
|
||||
if not isinstance(error, dict):
|
||||
return None
|
||||
if type(error.get("code")) is not int or not isinstance(error.get("message"), str):
|
||||
return None
|
||||
result = obj.get("result")
|
||||
mcp_shape = isinstance(result, dict) and any(
|
||||
isinstance(result.get(key), list) for key in ("tools", "content", "resources", "prompts")
|
||||
)
|
||||
return "response", mcp_shape
|
||||
|
||||
|
||||
@dataclass
|
||||
class Artifact:
|
||||
address: int
|
||||
encoding: str
|
||||
raw: bytes
|
||||
state: str
|
||||
kind: str
|
||||
obj: dict | None = None
|
||||
mcp: bool = False
|
||||
pid: int = 0
|
||||
vad_start: int = 0
|
||||
relation: str = "not-applicable"
|
||||
candidate_addresses: list[int] = field(default_factory=list)
|
||||
candidate_count: int = 0
|
||||
findings: list[dict] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def method(self) -> str:
|
||||
return (self.obj or {}).get("method", "")
|
||||
|
||||
@property
|
||||
def request_id(self) -> str | int | None:
|
||||
return (self.obj or {}).get("id")
|
||||
|
||||
@property
|
||||
def tool(self) -> str:
|
||||
params = (self.obj or {}).get("params")
|
||||
if self.method == "tools/call" and isinstance(params, dict):
|
||||
name = params.get("name")
|
||||
return name if isinstance(name, str) else ""
|
||||
return ""
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(self.raw).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"pid": self.pid,
|
||||
"vad_start": self.vad_start,
|
||||
"virtual_address": self.address,
|
||||
"encoding": self.encoding,
|
||||
"state": self.state,
|
||||
"kind": self.kind,
|
||||
"mcp_candidate": self.mcp,
|
||||
"request_id": self.request_id,
|
||||
"method": self.method,
|
||||
"tool": self.tool,
|
||||
"message": self.obj,
|
||||
"raw_base64": base64.b64encode(self.raw).decode("ascii"),
|
||||
"raw_sha256": self.sha256,
|
||||
"byte_length": len(self.raw),
|
||||
"relation": self.relation,
|
||||
"candidate_virtual_addresses": self.candidate_addresses,
|
||||
"candidate_address_count": self.candidate_count,
|
||||
"candidate_addresses_truncated": self.candidate_count > len(self.candidate_addresses),
|
||||
"findings": self.findings,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanStats:
|
||||
bytes_read: int = 0
|
||||
unreadable_bytes: int = 0
|
||||
anchor_limit_hits: int = 0
|
||||
candidate_limit_hits: int = 0
|
||||
|
||||
|
||||
def _object_end(data: bytes, start: int, step: int, limit: int) -> int | None:
|
||||
"""Respect quoted braces, escapes, arrays and a bounded nesting depth."""
|
||||
stack: list[int] = []
|
||||
quoted = escaped = False
|
||||
for pos in range(start, min(len(data), start + limit) - step + 1, step):
|
||||
char = data[pos] if step == 1 else int.from_bytes(data[pos : pos + 2], "little")
|
||||
if char == 0:
|
||||
return None
|
||||
if quoted:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == 92:
|
||||
escaped = True
|
||||
elif char == 34:
|
||||
quoted = False
|
||||
continue
|
||||
if char == 34:
|
||||
quoted = True
|
||||
elif char in (123, 91):
|
||||
stack.append(char)
|
||||
if len(stack) > 128:
|
||||
return None
|
||||
elif char in (125, 93):
|
||||
if not stack or stack.pop() != {125: 123, 93: 91}[char]:
|
||||
return None
|
||||
if not stack:
|
||||
return pos + step
|
||||
return None
|
||||
|
||||
|
||||
def _carve_buffer(
|
||||
data: bytes, base: int, maximum: int, partial: bool, stats: ScanStats
|
||||
) -> Iterator[Artifact]:
|
||||
for encoding, (anchors, starts_pattern) in PATTERNS.items():
|
||||
step = 1 if encoding == "utf-8" else 2
|
||||
starts = [m.start() for m in starts_pattern.finditer(data)]
|
||||
parsed: dict[int, Artifact | None] = {}
|
||||
complete: dict[int, Artifact] = {}
|
||||
unmatched: list[int] = []
|
||||
for count, match in enumerate(anchors.finditer(data)):
|
||||
if count >= 20000:
|
||||
stats.anchor_limit_hits += 1
|
||||
break
|
||||
anchor = match.start()
|
||||
low = bisect.bisect_left(starts, max(0, anchor - maximum + 1))
|
||||
high = bisect.bisect_right(starts, anchor)
|
||||
if high - low > 64:
|
||||
stats.candidate_limit_hits += 1
|
||||
low = high - 64
|
||||
found = False
|
||||
for index in range(high - 1, low - 1, -1):
|
||||
start = starts[index]
|
||||
if (anchor - start) % step:
|
||||
continue
|
||||
if start not in parsed:
|
||||
parsed[start] = None
|
||||
end = _object_end(data, start, step, maximum)
|
||||
if end is not None and end > anchor:
|
||||
raw = data[start:end]
|
||||
try:
|
||||
obj = strict_loads(raw.decode(encoding, errors="strict"))
|
||||
classification = classify(obj)
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
classification = None
|
||||
if classification:
|
||||
kind, mcp = classification
|
||||
parsed[start] = Artifact(
|
||||
base + start, encoding, raw, "complete", kind, obj, mcp
|
||||
)
|
||||
record = parsed[start]
|
||||
if record and record.address + len(record.raw) > base + anchor:
|
||||
complete[start] = record
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
unmatched.append(anchor)
|
||||
yield from complete.values()
|
||||
if partial:
|
||||
ranges = sorted((start, start + len(rec.raw)) for start, rec in complete.items())
|
||||
range_starts = [start for start, _ in ranges]
|
||||
for anchor in unmatched:
|
||||
index = bisect.bisect_right(range_starts, anchor) - 1
|
||||
if index >= 0 and anchor < ranges[index][1]:
|
||||
continue
|
||||
end = min(len(data), anchor + min(maximum, 512))
|
||||
end -= (end - anchor) % step
|
||||
for pos in range(anchor, end, step):
|
||||
if data[pos : pos + step] == b"\0" * step:
|
||||
end = pos
|
||||
break
|
||||
raw = data[anchor:end]
|
||||
if raw:
|
||||
# Raw fragment only: no guessed ID, arguments, or repaired JSON.
|
||||
yield Artifact(base + anchor, encoding, raw, "fragment", "fragment")
|
||||
|
||||
|
||||
def scan_chunks(
|
||||
chunks: Iterable[tuple[int, bytes]],
|
||||
*,
|
||||
max_object_bytes: int = 65536,
|
||||
include_partial: bool = False,
|
||||
stats: ScanStats | None = None,
|
||||
) -> Iterator[Artifact]:
|
||||
"""Carve contiguous virtual bytes, flushing at gaps and at every VAD boundary.
|
||||
|
||||
Keep two windows so a late jsonrpc member and a chunk-spanning object survive.
|
||||
No bytes are invented for unreadable pages. Addresses are process virtual addresses.
|
||||
"""
|
||||
if not 256 <= max_object_bytes <= 4 * 1024 * 1024:
|
||||
raise ValueError("max_object_bytes must be between 256 and 4194304")
|
||||
stats = stats if stats is not None else ScanStats()
|
||||
pending = b""
|
||||
base = 0
|
||||
seen: set[tuple[int, str]] = set()
|
||||
|
||||
def emit(final: bool) -> Iterator[Artifact]:
|
||||
safe_end = base + len(pending) if final else base + len(pending) - max_object_bytes
|
||||
for record in _carve_buffer(pending, base, max_object_bytes, include_partial, stats):
|
||||
key = record.address, record.encoding
|
||||
if record.address < safe_end and key not in seen:
|
||||
seen.add(key)
|
||||
yield record
|
||||
|
||||
for address, data in chunks:
|
||||
if not data:
|
||||
continue
|
||||
if pending and address != base + len(pending):
|
||||
yield from emit(True)
|
||||
pending = b""
|
||||
if not pending:
|
||||
base = address
|
||||
pending += data
|
||||
yield from emit(False)
|
||||
keep = 2 * max_object_bytes
|
||||
if len(pending) > keep:
|
||||
base += len(pending) - keep
|
||||
pending = pending[-keep:]
|
||||
if pending:
|
||||
yield from emit(True)
|
||||
|
||||
|
||||
def correlate(records: list[Artifact]) -> None:
|
||||
"""ID matches are candidates, never inferred sessions or verified executions."""
|
||||
groups: dict[tuple, list[Artifact]] = defaultdict(list)
|
||||
for record in records:
|
||||
record.candidate_addresses = []
|
||||
record.candidate_count = 0
|
||||
if record.state != "complete" or record.request_id is None:
|
||||
record.relation = "not-applicable"
|
||||
continue
|
||||
groups[(record.pid, type(record.request_id), record.request_id)].append(record)
|
||||
for group in groups.values():
|
||||
requests = [r for r in group if r.kind == "request"]
|
||||
responses = [r for r in group if r.kind == "response"]
|
||||
|
||||
def identities(items: list[Artifact]) -> set[str]:
|
||||
# Do not collapse distinct numeric lexemes through float rounding.
|
||||
# Different JSON formatting is conservatively considered ambiguous.
|
||||
return {r.raw.decode(r.encoding) for r in items}
|
||||
|
||||
ambiguous = len(identities(requests)) > 1 or len(identities(responses)) > 1
|
||||
request_addresses = sorted({r.address for r in requests})
|
||||
response_addresses = sorted({r.address for r in responses})
|
||||
for record in group:
|
||||
opposite = response_addresses if record.kind == "request" else request_addresses
|
||||
record.candidate_count = len(opposite)
|
||||
# Bound repeated address lists; the individual artifacts are retained.
|
||||
record.candidate_addresses = opposite[:256]
|
||||
record.relation = (
|
||||
"ambiguous-id" if ambiguous else "candidate-only" if opposite else "unpaired"
|
||||
)
|
||||
|
||||
|
||||
def relevant_records(records: list[Artifact], include_rpc: bool = False) -> list[Artifact]:
|
||||
"""Retain generic responses only when MCP-shaped or sharing a process/typed ID."""
|
||||
ids = {
|
||||
(r.pid, type(r.request_id), r.request_id)
|
||||
for r in records
|
||||
if r.mcp and r.kind == "request" and r.request_id is not None
|
||||
}
|
||||
return [
|
||||
r
|
||||
for r in records
|
||||
if include_rpc
|
||||
or r.mcp
|
||||
or r.state == "fragment"
|
||||
or (r.kind == "response" and (r.pid, type(r.request_id), r.request_id) in ids)
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Read a Volatility layer without filling missing evidence with zero bytes."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from mcptrace.core import ScanStats
|
||||
|
||||
|
||||
def readable_chunks(
|
||||
layer,
|
||||
start: int,
|
||||
size: int,
|
||||
stats: ScanStats,
|
||||
invalid_address: type[Exception],
|
||||
chunk_bytes: int = 1024 * 1024,
|
||||
) -> Iterator[tuple[int, bytes]]:
|
||||
if size < 0 or start < 0 or chunk_bytes < 4096:
|
||||
raise ValueError("Invalid virtual range or read chunk size")
|
||||
end = start + size
|
||||
offset = start
|
||||
while offset < end:
|
||||
amount = min(chunk_bytes, end - offset)
|
||||
try:
|
||||
data = layer.read(offset, amount, pad=False)
|
||||
if len(data) != amount:
|
||||
raise invalid_address(layer.name, offset, "Short read")
|
||||
except invalid_address:
|
||||
# One absent page must not discard other resident pages in this chunk.
|
||||
page = offset
|
||||
run_start = page
|
||||
run = bytearray()
|
||||
while page < offset + amount:
|
||||
page_amount = min(4096 - page % 4096, offset + amount - page)
|
||||
try:
|
||||
part = layer.read(page, page_amount, pad=False)
|
||||
if len(part) != page_amount:
|
||||
raise invalid_address(layer.name, page, "Short read")
|
||||
except invalid_address:
|
||||
if run:
|
||||
yield run_start, bytes(run)
|
||||
run.clear()
|
||||
stats.unreadable_bytes += page_amount
|
||||
else:
|
||||
if not run:
|
||||
run_start = page
|
||||
run.extend(part)
|
||||
stats.bytes_read += len(part)
|
||||
page += page_amount
|
||||
if run:
|
||||
yield run_start, bytes(run)
|
||||
else:
|
||||
stats.bytes_read += len(data)
|
||||
yield offset, data
|
||||
offset += amount
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Explicit analyst policy checks on complete tools/call arguments only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ntpath
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from mcptrace.core import Artifact
|
||||
|
||||
OPERATORS = {"equals", "contains", "url_host_not_in", "windows_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"] == "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 _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
|
||||
path = _absolute_windows(value)
|
||||
if path is None:
|
||||
return None
|
||||
for allowed in expected:
|
||||
root = _absolute_windows(allowed)
|
||||
try:
|
||||
if root is not None and ntpath.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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Extend the Windows plugin namespace without hiding built-in plugins."""
|
||||
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""MCP artifacts in Windows process virtual memory (Volatility 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from volatility3.framework import exceptions, interfaces, renderers
|
||||
from volatility3.framework.configuration import requirements
|
||||
from volatility3.framework.objects import utility
|
||||
from volatility3.framework.renderers import format_hints
|
||||
from volatility3.plugins.windows import pslist
|
||||
|
||||
from mcptrace import __version__
|
||||
from mcptrace.core import ScanStats, correlate, relevant_records, scan_chunks, strict_loads
|
||||
from mcptrace.memory import readable_chunks
|
||||
from mcptrace.policy import evaluate, validate_policy
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json(value) -> str:
|
||||
# Escape terminal control characters and preserve string versus numeric IDs.
|
||||
return json.dumps(value, ensure_ascii=True, separators=(",", ":"))
|
||||
|
||||
|
||||
class MCPTrace(interfaces.plugins.PluginInterface):
|
||||
"""Extract MCP/JSON-RPC candidates and policy findings from Windows VADs."""
|
||||
|
||||
_required_framework_version = (2, 28, 0)
|
||||
_version = (0, 1, 0)
|
||||
|
||||
@classmethod
|
||||
def get_requirements(cls):
|
||||
return [
|
||||
requirements.ModuleRequirement(
|
||||
name="kernel", description="Windows x64 kernel", architectures=["Intel64"]
|
||||
),
|
||||
requirements.VersionRequirement(
|
||||
name="pslist", component=pslist.PsList, version=(3, 0, 0)
|
||||
),
|
||||
requirements.ListRequirement(
|
||||
name="pid",
|
||||
description="Only scan these process IDs",
|
||||
element_type=int,
|
||||
optional=True,
|
||||
),
|
||||
requirements.IntRequirement(
|
||||
name="max-object-bytes",
|
||||
description="Maximum complete JSON size in bytes",
|
||||
default=65536,
|
||||
optional=True,
|
||||
),
|
||||
requirements.IntRequirement(
|
||||
name="max-records",
|
||||
description="Maximum candidate artifacts per process",
|
||||
default=10000,
|
||||
optional=True,
|
||||
),
|
||||
requirements.IntRequirement(
|
||||
name="max-bytes",
|
||||
description="VAD bytes to attempt per process; 0 is unlimited",
|
||||
default=0,
|
||||
optional=True,
|
||||
),
|
||||
requirements.BooleanRequirement(
|
||||
name="include-partial",
|
||||
description="Include raw, unparsed JSON-RPC fragments",
|
||||
default=False,
|
||||
optional=True,
|
||||
),
|
||||
requirements.BooleanRequirement(
|
||||
name="include-rpc",
|
||||
description="Also show unrelated generic JSON-RPC artifacts",
|
||||
default=False,
|
||||
optional=True,
|
||||
),
|
||||
requirements.StringRequirement(
|
||||
name="policy",
|
||||
description="Local JSON policy path (optional; no built-in verdicts)",
|
||||
optional=True,
|
||||
),
|
||||
requirements.BooleanRequirement(
|
||||
name="suspicious-only",
|
||||
description="Show policy matches only, plus scan summaries",
|
||||
default=False,
|
||||
optional=True,
|
||||
),
|
||||
requirements.BooleanRequirement(
|
||||
name="dump",
|
||||
description="Export JSONL with exact raw bytes and SHA-256 using -o",
|
||||
default=False,
|
||||
optional=True,
|
||||
),
|
||||
]
|
||||
|
||||
def _scan_process(self, proc, stats, records, limits):
|
||||
layer = self.context.layers[proc.add_process_layer()]
|
||||
pid = int(proc.UniqueProcessId)
|
||||
seen = set()
|
||||
attempted = 0
|
||||
maximum = self.config["max-object-bytes"]
|
||||
budget = self.config["max-bytes"]
|
||||
for vad in proc.get_vad_root().traverse():
|
||||
start, size = int(vad.get_start()), int(vad.get_size())
|
||||
if start < 0 or size <= 0:
|
||||
continue
|
||||
if budget and attempted >= budget:
|
||||
limits.append("max-bytes")
|
||||
break
|
||||
amount = min(size, budget - attempted) if budget else size
|
||||
chunks = readable_chunks(
|
||||
layer, start, amount, stats, exceptions.InvalidAddressException
|
||||
)
|
||||
for record in scan_chunks(
|
||||
chunks,
|
||||
max_object_bytes=maximum,
|
||||
include_partial=self.config["include-partial"],
|
||||
stats=stats,
|
||||
):
|
||||
key = record.address, record.encoding
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
record.pid, record.vad_start = pid, start
|
||||
records.append(record)
|
||||
if len(records) >= self.config["max-records"]:
|
||||
limits.append("max-records")
|
||||
return
|
||||
attempted += amount
|
||||
if amount < size:
|
||||
limits.append("max-bytes")
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def _row(pid, name, record):
|
||||
return (
|
||||
pid,
|
||||
name,
|
||||
format_hints.Hex(record.vad_start),
|
||||
format_hints.Hex(record.address),
|
||||
record.encoding,
|
||||
record.state,
|
||||
record.kind,
|
||||
_json(record.request_id),
|
||||
_json(record.method),
|
||||
_json(record.tool),
|
||||
record.relation,
|
||||
_json(
|
||||
{
|
||||
"addresses": [hex(a) for a in record.candidate_addresses],
|
||||
"total": record.candidate_count,
|
||||
"truncated": record.candidate_count > len(record.candidate_addresses),
|
||||
}
|
||||
),
|
||||
_json(record.findings),
|
||||
_json(record.raw.decode(record.encoding, errors="backslashreplace")),
|
||||
record.sha256,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _summary_row(pid, name, summary):
|
||||
return (
|
||||
pid,
|
||||
name,
|
||||
format_hints.Hex(0),
|
||||
format_hints.Hex(0),
|
||||
"",
|
||||
"scan-summary",
|
||||
"scan-summary",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
_json(summary),
|
||||
"",
|
||||
)
|
||||
|
||||
def _export(self, pid, process, records, summary):
|
||||
with self.open(f"mcptrace.pid-{pid}.jsonl") as output:
|
||||
header = {
|
||||
"type": "metadata",
|
||||
"mcptrace_version": __version__,
|
||||
"pid": pid,
|
||||
"process": process,
|
||||
"address_space": "process-virtual",
|
||||
"correlation": "same PID and typed ID only; session/direction not recovered",
|
||||
}
|
||||
output.write((_json(header) + "\n").encode())
|
||||
for record in records:
|
||||
output.write((_json({"type": "artifact", **record.to_dict()}) + "\n").encode())
|
||||
output.write((_json({"type": "scan-summary", **summary}) + "\n").encode())
|
||||
# The CLI may rename an existing destination when the handler closes.
|
||||
return output.preferred_filename
|
||||
|
||||
def _generator(self, rules):
|
||||
filters = pslist.PsList.create_pid_filter(self.config.get("pid"))
|
||||
processes = pslist.PsList.list_processes(
|
||||
context=self.context, kernel_module_name=self.config["kernel"], filter_func=filters
|
||||
)
|
||||
for proc in processes:
|
||||
pid = int(proc.UniqueProcessId)
|
||||
try:
|
||||
name = utility.array_to_string(proc.ImageFileName)
|
||||
except exceptions.InvalidAddressException:
|
||||
name = "<unreadable>"
|
||||
display_name = _json(name)[1:-1]
|
||||
stats = ScanStats()
|
||||
error = None
|
||||
records, limits = [], []
|
||||
try:
|
||||
self._scan_process(proc, stats, records, limits)
|
||||
except (exceptions.InvalidAddressException, exceptions.SymbolError) as exc:
|
||||
# Keep evidence recovered before a later VAD or symbol failure.
|
||||
limits.append("process-read-error")
|
||||
error = str(exc)
|
||||
LOG.warning("PID %d: %s", pid, exc)
|
||||
correlate(records)
|
||||
for record in records:
|
||||
record.findings = evaluate(record, rules)
|
||||
relevant = relevant_records(records, self.config["include-rpc"])
|
||||
visible = relevant
|
||||
if self.config["suspicious-only"]:
|
||||
visible = [
|
||||
r for r in relevant if any(f["status"] == "policy-match" for f in r.findings)
|
||||
]
|
||||
summary = {
|
||||
**asdict(stats),
|
||||
"candidate_records": len(records),
|
||||
"relevant_records": len(relevant),
|
||||
"displayed_records": len(visible),
|
||||
"limits_hit": limits,
|
||||
"error": error,
|
||||
"scope": "readable bytes exposed by pslist process VAD layers",
|
||||
"absence_is_not_proof": True,
|
||||
}
|
||||
if limits or stats.anchor_limit_hits or stats.candidate_limit_hits:
|
||||
LOG.warning("PID %d scan is limited: %s", pid, _json(summary))
|
||||
if self.config["dump"]:
|
||||
summary["export"] = self._export(pid, name, visible, summary)
|
||||
for record in sorted(visible, key=lambda r: (r.address, r.encoding)):
|
||||
yield 0, self._row(pid, display_name, record)
|
||||
yield 0, self._summary_row(pid, display_name, summary)
|
||||
|
||||
def run(self):
|
||||
maximum = self.config["max-object-bytes"]
|
||||
if not 256 <= maximum <= 4 * 1024 * 1024:
|
||||
raise ValueError("--max-object-bytes must be between 256 and 4194304")
|
||||
if not 1 <= self.config["max-records"] <= 100000:
|
||||
raise ValueError("--max-records must be between 1 and 100000")
|
||||
if self.config["max-bytes"] < 0:
|
||||
raise ValueError("--max-bytes cannot be negative")
|
||||
rules = []
|
||||
if self.config.get("policy"):
|
||||
policy_path = Path(self.config["policy"])
|
||||
if policy_path.stat().st_size > 1024 * 1024:
|
||||
raise ValueError("Policy file exceeds 1 MiB")
|
||||
rules = validate_policy(strict_loads(policy_path.read_text(encoding="utf-8")))
|
||||
if self.config["suspicious-only"] and not rules:
|
||||
raise ValueError("--suspicious-only requires a nonempty --policy")
|
||||
return renderers.TreeGrid(
|
||||
[
|
||||
("PID", int),
|
||||
("Process", str),
|
||||
("VADStart", format_hints.Hex),
|
||||
("VirtualAddress", format_hints.Hex),
|
||||
("Encoding", str),
|
||||
("State", str),
|
||||
("Kind", str),
|
||||
("RequestID", str),
|
||||
("Method", str),
|
||||
("Tool", str),
|
||||
("Relation", str),
|
||||
("CandidateAddresses", str),
|
||||
("Findings", str),
|
||||
("Raw", str),
|
||||
("SHA256", str),
|
||||
],
|
||||
self._generator(rules),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "mcptrace"
|
||||
version = "0.1.0"
|
||||
description = "Evidence-preserving MCP artifact analysis for Volatility 3 on Windows"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["volatility3==2.28.2"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8,<10", "ruff>=0.9,<1"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["mcptrace*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
@@ -0,0 +1,190 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mcptrace.core import ScanStats, classify, correlate, relevant_records, scan_chunks
|
||||
|
||||
|
||||
def request(rid=1, text="hello", tool="echo", arguments=None):
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": rid,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": tool,
|
||||
"arguments": arguments if arguments is not None else {"text": text},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def encode(obj, encoding="utf-8"):
|
||||
return json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode(encoding)
|
||||
|
||||
|
||||
def carve(obj, **kwargs):
|
||||
return list(scan_chunks([(0x1000, encode(obj))], **kwargs))[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["utf-8", "utf-16-le"])
|
||||
@pytest.mark.parametrize(
|
||||
"text", ["normal", "unbalanced }", "unbalanced {", 'quote \\" and }', "한글😀"]
|
||||
)
|
||||
def test_intact_message_and_exact_provenance(encoding, text):
|
||||
obj = request(text=text)
|
||||
raw = encode(obj, encoding)
|
||||
records = list(scan_chunks([(0x1001, b"xyz" + raw + b"\0" * 8)]))
|
||||
assert len(records) == 1
|
||||
record = records[0]
|
||||
assert record.address == 0x1004
|
||||
assert record.obj == obj and record.encoding == encoding
|
||||
assert record.raw == raw and record.sha256 == hashlib.sha256(raw).hexdigest()
|
||||
assert base64.b64decode(record.to_dict()["raw_base64"]) == raw
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["utf-8", "utf-16-le"])
|
||||
def test_last_jsonrpc_member_and_every_chunk_boundary(encoding):
|
||||
obj = {
|
||||
"params": {"name": "echo", "arguments": {"a": {"b": "} { 한글"}}},
|
||||
"method": "tools/call",
|
||||
"id": 7,
|
||||
"jsonrpc": "2.0",
|
||||
}
|
||||
raw = encode(obj, encoding)
|
||||
for split in range(1, len(raw)):
|
||||
records = list(scan_chunks([(123, raw[:split]), (123 + split, raw[split:])]))
|
||||
assert len(records) == 1 and records[0].obj == obj
|
||||
|
||||
|
||||
def test_large_stream_overlap_keeps_one_record_per_address():
|
||||
raw = encode(request())
|
||||
data = b"\0" * 500 + raw + b"\0" * 2000 + raw + b"\0" * 600
|
||||
records = list(
|
||||
scan_chunks(
|
||||
((i, data[i : i + 128]) for i in range(0, len(data), 128)), max_object_bytes=256
|
||||
)
|
||||
)
|
||||
assert [r.address for r in records] == [500, 500 + len(raw) + 2000]
|
||||
|
||||
|
||||
def test_gap_is_not_filled_or_joined():
|
||||
raw = encode(request(text="x" * 100))
|
||||
chunks = [(0x1000, raw[:80]), (0x3000, raw[80:])]
|
||||
records = list(scan_chunks(chunks, include_partial=True))
|
||||
assert records and all(r.state == "fragment" for r in records)
|
||||
assert all(r.obj is None and r.request_id is None and not r.tool for r in records)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
b'{"jsonrpc":"2.0","id":1,"method":"tools/list","id":2}',
|
||||
b'{"jsonrpc":"2.0","id":true,"method":"tools/list"}',
|
||||
b'{"jsonrpc":"2.0","id":1.5,"method":"tools/list"}',
|
||||
b'{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":1,"message":"x"}}',
|
||||
b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"x":NaN}}',
|
||||
b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"x":1e999}}',
|
||||
b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"x":"\xff"}}',
|
||||
],
|
||||
)
|
||||
def test_malformed_data_not_silently_repaired(raw):
|
||||
assert list(scan_chunks([(0, raw)])) == []
|
||||
fragments = list(scan_chunks([(0, raw)], include_partial=True))
|
||||
assert fragments and all(r.state == "fragment" and r.obj is None for r in fragments)
|
||||
|
||||
|
||||
def test_plain_text_mentions_and_non_rpc_json_do_not_count():
|
||||
assert list(scan_chunks([(0, b'tools/call jsonrpc {"method":"tools/call"}')])) == []
|
||||
|
||||
|
||||
def test_notifications_and_errors():
|
||||
assert carve({"jsonrpc": "2.0", "method": "notifications/initialized"}).kind == "notification"
|
||||
response = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse"}}
|
||||
assert carve(response).kind == "response"
|
||||
assert classify({"jsonrpc": "2.0", "id": 1, "error": {"code": True, "message": "x"}}) is None
|
||||
|
||||
|
||||
def test_pid_and_id_type_boundaries():
|
||||
req = carve(request(1))
|
||||
other_pid = carve({"jsonrpc": "2.0", "id": 1, "result": {}})
|
||||
other_pid.pid = 2
|
||||
string_id = carve({"jsonrpc": "2.0", "id": "1", "result": {}})
|
||||
records = [req, other_pid, string_id]
|
||||
correlate(records)
|
||||
assert all(r.relation == "unpaired" for r in records)
|
||||
assert relevant_records(records) == [req]
|
||||
|
||||
|
||||
def test_pair_is_only_a_candidate_and_copies_keep_addresses():
|
||||
req, duplicate = carve(request()), carve(request())
|
||||
duplicate.address += 500
|
||||
response = carve({"jsonrpc": "2.0", "id": 1, "result": {}})
|
||||
response.address += 1000
|
||||
records = [req, duplicate, response]
|
||||
correlate(records)
|
||||
assert all(r.relation == "candidate-only" for r in records)
|
||||
assert response.candidate_addresses == [req.address, duplicate.address]
|
||||
assert len(relevant_records(records)) == 3
|
||||
|
||||
|
||||
def test_id_collision_is_ambiguous_without_inventing_session():
|
||||
records = [
|
||||
carve(request(text="session A")),
|
||||
carve(request(text="session B")),
|
||||
carve({"jsonrpc": "2.0", "id": 1, "result": {}}),
|
||||
]
|
||||
correlate(records)
|
||||
assert all(r.relation == "ambiguous-id" for r in records)
|
||||
assert all("session" not in r.to_dict() for r in records)
|
||||
|
||||
|
||||
def test_same_json_in_two_encodings_preserves_both_locations():
|
||||
obj = request()
|
||||
first = encode(obj)
|
||||
records = list(scan_chunks([(0, first + b"\0" * 8 + encode(obj, "utf-16-le"))]))
|
||||
assert len(records) == 2
|
||||
assert {r.encoding for r in records} == {"utf-8", "utf-16-le"}
|
||||
assert records[0].address != records[1].address
|
||||
|
||||
|
||||
def test_oversized_messages_are_not_complete():
|
||||
records = list(
|
||||
scan_chunks(
|
||||
[(0, encode(request(text="x" * 400)))], max_object_bytes=256, include_partial=True
|
||||
)
|
||||
)
|
||||
assert records and all(r.state == "fragment" for r in records)
|
||||
|
||||
|
||||
def test_backtrack_limit_is_observable():
|
||||
obj = {
|
||||
"params": {"x": [{"v": i} for i in range(70)]},
|
||||
"method": "tools/list",
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
}
|
||||
stats = ScanStats()
|
||||
list(scan_chunks([(0, encode(obj))], stats=stats))
|
||||
assert stats.candidate_limit_hits > 0
|
||||
|
||||
|
||||
def test_candidate_addresses_are_bounded_without_dropping_artifacts():
|
||||
req = carve(request())
|
||||
responses = [carve({"jsonrpc": "2.0", "id": 1, "result": {}}) for _ in range(300)]
|
||||
for i, response in enumerate(responses):
|
||||
response.address += i * 100
|
||||
records = [req, *responses]
|
||||
correlate(records)
|
||||
assert len(relevant_records(records)) == 301
|
||||
assert len(req.candidate_addresses) == 256 and req.candidate_count == 300
|
||||
assert req.to_dict()["candidate_addresses_truncated"] is True
|
||||
|
||||
|
||||
def test_distinct_numeric_lexemes_are_not_collapsed_by_float_rounding():
|
||||
template = b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"value":%s}}'
|
||||
data = template % b"1.00000000000000001" + b"\0" + template % b"1.00000000000000002"
|
||||
records = list(scan_chunks([(0, data)]))
|
||||
correlate(records)
|
||||
assert len(records) == 2
|
||||
assert all(r.relation == "ambiguous-id" for r in records)
|
||||
@@ -0,0 +1,216 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from test_core import encode, request
|
||||
from volatility3.cli import CommandLine
|
||||
from volatility3.framework import contexts, exceptions
|
||||
from volatility3.framework.layers.physical import BufferDataLayer
|
||||
|
||||
from mcptrace.core import ScanStats, scan_chunks
|
||||
from mcptrace.memory import readable_chunks
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def plugin_module():
|
||||
import volatility3.plugins.windows
|
||||
|
||||
plugin_path = str(ROOT / "plugins" / "windows")
|
||||
if plugin_path not in volatility3.plugins.windows.__path__:
|
||||
volatility3.plugins.windows.__path__.insert(0, plugin_path)
|
||||
return importlib.import_module("volatility3.plugins.windows.mcptrace")
|
||||
|
||||
|
||||
class GappedLayer(BufferDataLayer):
|
||||
def read(self, offset, length, pad=False):
|
||||
if offset < 8192 and offset + length > 4096:
|
||||
raise exceptions.InvalidAddressException(self.name, max(offset, 4096))
|
||||
return super().read(offset, length, pad=pad)
|
||||
|
||||
|
||||
def test_real_volatility_layer_does_not_bridge_missing_page():
|
||||
context = contexts.Context()
|
||||
intact = encode(request(2))
|
||||
split = encode(request(1))
|
||||
data = bytearray(b"\0" * 12288)
|
||||
data[4000:4096] = split[:96]
|
||||
data[8192 : 8192 + len(split[96:])] = split[96:]
|
||||
data[9000 : 9000 + len(intact)] = intact
|
||||
layer = GappedLayer(context, "memory", "memory", bytes(data))
|
||||
stats = ScanStats()
|
||||
records = list(
|
||||
scan_chunks(
|
||||
readable_chunks(layer, 0, len(data), stats, exceptions.InvalidAddressException),
|
||||
include_partial=True,
|
||||
)
|
||||
)
|
||||
assert [r.request_id for r in records if r.state == "complete"] == [2]
|
||||
assert stats.bytes_read == 8192 and stats.unreadable_bytes == 4096
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(monkeypatch):
|
||||
module = plugin_module()
|
||||
context = contexts.Context()
|
||||
req = encode(request(tool="read_file", arguments={"path": r"C:\Secret\key.txt"}))
|
||||
resp = encode({"jsonrpc": "2.0", "id": 1, "result": {"content": []}}, "utf-16-le")
|
||||
memory = b"\0" * 256 + req + b"\0" * 100 + resp + b"\0" * 256
|
||||
context.layers.add_layer(BufferDataLayer(context, "mem", "mem", memory))
|
||||
vad = SimpleNamespace(get_start=lambda: 0, get_size=lambda: len(memory))
|
||||
proc = SimpleNamespace(
|
||||
UniqueProcessId=42,
|
||||
ImageFileName="agent.exe",
|
||||
add_process_layer=lambda: "mem",
|
||||
get_vad_root=lambda: SimpleNamespace(traverse=lambda: iter([vad])),
|
||||
)
|
||||
monkeypatch.setattr(module.pslist.PsList, "list_processes", lambda **kw: iter([proc]))
|
||||
monkeypatch.setattr(module.utility, "array_to_string", lambda value: value)
|
||||
requirements = module.MCPTrace.get_requirements()
|
||||
# Synthetic EPROCESS/VADs, real Volatility memory layer and TreeGrid. This does
|
||||
# not replace a real Windows kernel/symbol integration test.
|
||||
monkeypatch.setattr(module.MCPTrace, "get_requirements", classmethod(lambda cls: []))
|
||||
context.config["plugins.MCPTrace.kernel"] = "synthetic-kernel"
|
||||
for requirement in requirements:
|
||||
if requirement.name not in {"kernel", "pslist"}:
|
||||
context.config[f"plugins.MCPTrace.{requirement.name}"] = requirement.default
|
||||
plugin = module.MCPTrace(context, "plugins.MCPTrace")
|
||||
return plugin, proc
|
||||
|
||||
|
||||
def rows_of(plugin):
|
||||
rows = []
|
||||
grid = plugin.run()
|
||||
grid.populate(lambda node, acc: acc.append(tuple(node.values)) or acc, rows)
|
||||
return rows
|
||||
|
||||
|
||||
def test_adapter_treegrid_with_real_framework_and_policy(adapter):
|
||||
plugin, _ = adapter
|
||||
plugin.config["policy"] = str(ROOT / "examples" / "policy.json")
|
||||
rows = rows_of(plugin)
|
||||
artifacts = [row for row in rows if row[5] != "scan-summary"]
|
||||
assert len(artifacts) == 2
|
||||
assert {row[4] for row in artifacts} == {"utf-8", "utf-16-le"}
|
||||
assert all(row[0] == 42 and row[10] == "candidate-only" for row in artifacts)
|
||||
assert json.loads(artifacts[0][12])[0]["status"] == "policy-match"
|
||||
assert json.loads(rows[-1][13])["candidate_records"] == 2
|
||||
|
||||
|
||||
def test_limit_is_in_summary(adapter):
|
||||
plugin, _ = adapter
|
||||
plugin.config["max-records"] = 1
|
||||
rows = rows_of(plugin)
|
||||
assert json.loads(rows[-1][13])["limits_hit"] == ["max-records"]
|
||||
|
||||
|
||||
def test_suspicious_only_retains_summary(adapter):
|
||||
plugin, _ = adapter
|
||||
plugin.config["policy"] = str(ROOT / "examples" / "policy.json")
|
||||
plugin.config["suspicious-only"] = True
|
||||
rows = rows_of(plugin)
|
||||
assert len(rows) == 2 and rows[0][6] == "request" and rows[1][5] == "scan-summary"
|
||||
|
||||
|
||||
def test_suspicious_only_without_policy_fails(adapter):
|
||||
plugin, _ = adapter
|
||||
plugin.config["suspicious-only"] = True
|
||||
with pytest.raises(ValueError, match="nonempty"):
|
||||
plugin.run()
|
||||
|
||||
|
||||
def test_cli_plugin_discovery_and_help():
|
||||
executable = Path(sysconfig.get_path("scripts")) / (
|
||||
"vol.exe" if sys.platform == "win32" else "vol"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[str(executable), "-p", str(ROOT / "plugins"), "windows.mcptrace.MCPTrace", "-h"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "--include-partial" in result.stdout and "--max-object-bytes" in result.stdout
|
||||
|
||||
|
||||
def test_later_vad_failure_keeps_prior_evidence(adapter):
|
||||
plugin, proc = adapter
|
||||
first = next(proc.get_vad_root().traverse())
|
||||
|
||||
def broken_traversal():
|
||||
yield first
|
||||
raise exceptions.InvalidAddressException("synthetic-kernel", 0xBAD)
|
||||
|
||||
proc.get_vad_root = lambda: SimpleNamespace(traverse=broken_traversal)
|
||||
rows = rows_of(plugin)
|
||||
assert len([row for row in rows if row[5] == "complete"]) == 2
|
||||
summary = json.loads(rows[-1][13])
|
||||
assert summary["limits_hit"] == ["process-read-error"]
|
||||
assert summary["error"] is not None
|
||||
|
||||
|
||||
def test_byte_budget_keeps_summary_without_claiming_no_evidence(adapter):
|
||||
plugin, _ = adapter
|
||||
plugin.config["max-bytes"] = 128
|
||||
rows = rows_of(plugin)
|
||||
assert len(rows) == 1
|
||||
summary = json.loads(rows[0][13])
|
||||
assert summary["bytes_read"] == 128
|
||||
assert summary["limits_hit"] == ["max-bytes"] and summary["absence_is_not_proof"]
|
||||
|
||||
|
||||
def test_export_uses_real_cli_handler_and_roundtrips_raw_evidence(adapter, tmp_path):
|
||||
plugin, _ = adapter
|
||||
cli = CommandLine()
|
||||
cli.output_dir = str(tmp_path)
|
||||
plugin.set_open_method(cli.file_handler_class_factory())
|
||||
plugin.config["dump"] = True
|
||||
rows_of(plugin)
|
||||
exported = [
|
||||
json.loads(line) for line in (tmp_path / "mcptrace.pid-42.jsonl").read_text().splitlines()
|
||||
]
|
||||
assert exported[0]["type"] == "metadata"
|
||||
assert exported[-1]["type"] == "scan-summary"
|
||||
artifacts = [item for item in exported if item["type"] == "artifact"]
|
||||
assert len(artifacts) == 2
|
||||
for item in artifacts:
|
||||
raw = base64.b64decode(item["raw_base64"])
|
||||
assert hashlib.sha256(raw).hexdigest() == item["raw_sha256"]
|
||||
assert json.loads(raw.decode(item["encoding"])) == item["message"]
|
||||
|
||||
|
||||
def test_export_collision_reports_actual_filename(adapter, tmp_path):
|
||||
plugin, _ = adapter
|
||||
original = tmp_path / "mcptrace.pid-42.jsonl"
|
||||
original.write_text("existing evidence", encoding="utf-8")
|
||||
cli = CommandLine()
|
||||
cli.output_dir = str(tmp_path)
|
||||
plugin.set_open_method(cli.file_handler_class_factory())
|
||||
plugin.config["dump"] = True
|
||||
rows = rows_of(plugin)
|
||||
filename = json.loads(rows[-1][13])["export"]
|
||||
assert filename != original.name
|
||||
assert (tmp_path / filename).is_file()
|
||||
assert original.read_text() == "existing evidence"
|
||||
|
||||
|
||||
def test_adjacent_vads_are_never_joined(adapter):
|
||||
plugin, proc = adapter
|
||||
data = encode(request(text="x" * 200))
|
||||
context = plugin.context
|
||||
context.layers.add_layer(BufferDataLayer(context, "split", "split", data))
|
||||
proc.add_process_layer = lambda: "split"
|
||||
vads = [
|
||||
SimpleNamespace(get_start=lambda: 0, get_size=lambda: 80),
|
||||
SimpleNamespace(get_start=lambda: 80, get_size=lambda: len(data) - 80),
|
||||
]
|
||||
proc.get_vad_root = lambda: SimpleNamespace(traverse=lambda: iter(vads))
|
||||
rows = rows_of(plugin)
|
||||
assert len(rows) == 1 and rows[0][5] == "scan-summary"
|
||||
@@ -0,0 +1,112 @@
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
from test_core import carve, request
|
||||
|
||||
from mcptrace.policy import evaluate, validate_policy
|
||||
|
||||
|
||||
def rule(operator, value, argument="/path"):
|
||||
return {
|
||||
"id": "R1",
|
||||
"description": "Analyst-defined policy",
|
||||
"severity": "medium",
|
||||
"tool": "read_file",
|
||||
"argument": argument,
|
||||
"operator": operator,
|
||||
"value": value,
|
||||
}
|
||||
|
||||
|
||||
def run_rule(value, operator="windows_path_outside", expected=None):
|
||||
spec = rule(operator, expected if expected is not None else [r"C:\Cases"])
|
||||
rules = validate_policy({"version": 1, "rules": [spec]})
|
||||
return evaluate(carve(request(tool="read_file", arguments={"path": value})), rules)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[r"C:\Cases\evidence.txt", r"c:\CASES\sub\a.txt", r"C:/Cases/a.txt", r"\\?\C:\Cases\a.txt"],
|
||||
)
|
||||
def test_allowed_path_boundaries(value):
|
||||
assert run_rule(value) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [r"C:\Cases2\a.txt", r"C:\Cases\..\secret.txt", r"D:\Cases\a"])
|
||||
def test_outside_paths_match(value):
|
||||
assert run_rule(value)[0]["status"] == "policy-match"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
r"C:relative.txt",
|
||||
r"\rooted.txt",
|
||||
r"%USERPROFILE%\a",
|
||||
3,
|
||||
None,
|
||||
r"C:\Cases\a.txt:secret",
|
||||
r"C:\Cases\a. ",
|
||||
r"C:\Cases\*.txt",
|
||||
],
|
||||
)
|
||||
def test_unresolved_inputs_are_not_marked_safe(value):
|
||||
assert run_rule(value)[0]["status"] == "not-evaluated"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,matched",
|
||||
[
|
||||
("https://API.EXAMPLE.TEST./x", False),
|
||||
("https://api.example.test.evil.test/x", True),
|
||||
("https://api.example.test@evil.test/x", True),
|
||||
("https://evil.test/?allowed=api.example.test", True),
|
||||
("https://api.example.test:443/path", False),
|
||||
],
|
||||
)
|
||||
def test_url_host_uses_parsed_authority(url, matched):
|
||||
findings = run_rule(url, "url_host_not_in", ["api.example.test"])
|
||||
assert bool(findings) == matched
|
||||
|
||||
|
||||
def test_policy_requires_exact_tool_and_complete_request():
|
||||
rules = validate_policy({"version": 1, "rules": [rule("contains", "secret")]})
|
||||
assert evaluate(carve(request(tool="another_tool", arguments={"path": "secret"})), rules) == []
|
||||
record = carve(request(tool="read_file", arguments={"path": "secret"}))
|
||||
assert evaluate(record, rules)[0]["status"] == "policy-match"
|
||||
record.state = "fragment"
|
||||
assert evaluate(record, rules) == []
|
||||
|
||||
|
||||
def test_json_pointer_and_missing_arguments():
|
||||
spec = rule("equals", "secret", "/nested/a~1b/0")
|
||||
rules = validate_policy({"version": 1, "rules": [spec]})
|
||||
rec = carve(request(tool="read_file", arguments={"nested": {"a/b": ["secret"]}}))
|
||||
assert evaluate(rec, rules)[0]["status"] == "policy-match"
|
||||
rec.obj["params"]["arguments"] = {}
|
||||
assert evaluate(rec, rules)[0]["status"] == "not-evaluated"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
[
|
||||
{"operator": "execute"},
|
||||
{"severity": "critical"},
|
||||
{"extra": "ignored?"},
|
||||
{"argument": "path"},
|
||||
{"value": []},
|
||||
{"value": ["relative"]},
|
||||
{"argument": "/bad~2pointer"},
|
||||
],
|
||||
)
|
||||
def test_bad_policy_is_rejected(change):
|
||||
spec = rule("windows_path_outside", [r"C:\Cases"])
|
||||
spec.update(change)
|
||||
with pytest.raises(ValueError):
|
||||
validate_policy({"version": 1, "rules": [spec]})
|
||||
|
||||
|
||||
def test_duplicate_rule_ids_rejected():
|
||||
spec = rule("contains", "x")
|
||||
with pytest.raises(ValueError):
|
||||
validate_policy({"version": 1, "rules": [spec, copy.deepcopy(spec)]})
|
||||
Reference in New Issue
Block a user