mcp-api-pentest 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_api_logic_pentest/__init__.py +0 -0
- mcp_api_logic_pentest/adapters/__init__.py +0 -0
- mcp_api_logic_pentest/adapters/cli.py +65 -0
- mcp_api_logic_pentest/adapters/mcp_server.py +50 -0
- mcp_api_logic_pentest/adapters/tests/test_cli.py +122 -0
- mcp_api_logic_pentest/audit_context/__init__.py +0 -0
- mcp_api_logic_pentest/audit_context/attack_context.py +101 -0
- mcp_api_logic_pentest/audit_context/tests/__init__.py +1 -0
- mcp_api_logic_pentest/audit_context/tests/test_attack_context.py +134 -0
- mcp_api_logic_pentest/config/__init__.py +0 -0
- mcp_api_logic_pentest/config/settings.py +13 -0
- mcp_api_logic_pentest/http_client/__init__.py +0 -0
- mcp_api_logic_pentest/http_client/auth/__init__.py +0 -0
- mcp_api_logic_pentest/http_client/auth/bearer_auth.py +18 -0
- mcp_api_logic_pentest/http_client/http_client.py +84 -0
- mcp_api_logic_pentest/http_client/tests/test_http_client.py +89 -0
- mcp_api_logic_pentest/idor_detector/__init__.py +0 -0
- mcp_api_logic_pentest/idor_detector/access_analyzer.py +126 -0
- mcp_api_logic_pentest/idor_detector/tests/test_access_analyzer.py +58 -0
- mcp_api_logic_pentest/idor_detector/tests/test_volatile_cleaner.py +41 -0
- mcp_api_logic_pentest/idor_detector/volatile_cleaner.py +19 -0
- mcp_api_logic_pentest/orchestration/__init__.py +0 -0
- mcp_api_logic_pentest/orchestration/audit_pipeline.py +22 -0
- mcp_api_logic_pentest/report_generator/__init__.py +0 -0
- mcp_api_logic_pentest/report_generator/finding.py +24 -0
- mcp_api_logic_pentest/report_generator/markdown_reporter.py +110 -0
- mcp_api_logic_pentest/report_generator/reporter.py +43 -0
- mcp_api_logic_pentest/report_generator/tests/test_reporter.py +123 -0
- mcp_api_logic_pentest/shared/__init__.py +0 -0
- mcp_api_logic_pentest/shared/json_truncator.py +18 -0
- mcp_api_logic_pentest/shared/tests/test_json_truncator.py +125 -0
- mcp_api_logic_pentest/spec_analyzer/__init__.py +0 -0
- mcp_api_logic_pentest/spec_analyzer/spec_parser.py +47 -0
- mcp_api_logic_pentest/spec_analyzer/tests/test_spec_parser.py +86 -0
- mcp_api_pentest-0.2.0.dist-info/METADATA +288 -0
- mcp_api_pentest-0.2.0.dist-info/RECORD +40 -0
- mcp_api_pentest-0.2.0.dist-info/WHEEL +5 -0
- mcp_api_pentest-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_api_pentest-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_api_pentest-0.2.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""CLI entry point for mcp-api-logic-pentest."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
7
|
+
parser = argparse.ArgumentParser(
|
|
8
|
+
prog="mcp-api-pentest",
|
|
9
|
+
description="MCP server for autonomous API logic penetration testing",
|
|
10
|
+
)
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"--swagger", "-s",
|
|
13
|
+
help="Path to OpenAPI/Swagger spec file (JSON or YAML)",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--target", "-t",
|
|
17
|
+
help="Base URL of the API to audit",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--output", "-o",
|
|
21
|
+
default="REPORTE_PENTEST.md",
|
|
22
|
+
help="Output report path (default: REPORTE_PENTEST.md)",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--delay",
|
|
26
|
+
type=float,
|
|
27
|
+
default=0.5,
|
|
28
|
+
help="Delay in seconds between requests (default: 0.5)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--token-admin",
|
|
32
|
+
help="Admin-level authentication token for multi-session audits",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--token-victim",
|
|
36
|
+
help="Victim-level authentication token for multi-session audits",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--token-attacker",
|
|
40
|
+
help="Attacker-level authentication token for multi-session audits",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--config",
|
|
44
|
+
help="Path to YAML configuration file",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--mock",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Launch the mock API sandbox instead of the MCP server",
|
|
50
|
+
)
|
|
51
|
+
return parser
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> None:
|
|
55
|
+
args = build_parser().parse_args()
|
|
56
|
+
|
|
57
|
+
if args.mock:
|
|
58
|
+
print("Starting mock API sandbox...")
|
|
59
|
+
import subprocess
|
|
60
|
+
subprocess.run([sys.executable, "mock_api.py"])
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
print("Starting MCP API Logic Pentest server...")
|
|
64
|
+
from mcp_api_logic_pentest.adapters.mcp_server import mcp
|
|
65
|
+
mcp.run()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from fastmcp import FastMCP
|
|
2
|
+
|
|
3
|
+
from mcp_api_logic_pentest.audit_context.attack_context import (
|
|
4
|
+
capture_context,
|
|
5
|
+
clear_context,
|
|
6
|
+
extract_json_value,
|
|
7
|
+
get_context,
|
|
8
|
+
list_context,
|
|
9
|
+
)
|
|
10
|
+
from mcp_api_logic_pentest.http_client.http_client import execute_security_request
|
|
11
|
+
from mcp_api_logic_pentest.idor_detector.access_analyzer import analyze_access_control
|
|
12
|
+
from mcp_api_logic_pentest.report_generator.reporter import (
|
|
13
|
+
export_report_json,
|
|
14
|
+
generate_report,
|
|
15
|
+
save_security_finding,
|
|
16
|
+
)
|
|
17
|
+
from mcp_api_logic_pentest.spec_analyzer.spec_parser import parse_api_spec
|
|
18
|
+
|
|
19
|
+
mcp = FastMCP("mcp-api-logic-pentest")
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Fase 1 + 4: HTTP client and spec parsing
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
mcp.tool()(parse_api_spec)
|
|
26
|
+
mcp.tool()(execute_security_request)
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Fase 2: Multi-session IDOR detection
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
mcp.tool()(analyze_access_control)
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Fase 3: State management for chained attacks
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
mcp.tool()(capture_context)
|
|
39
|
+
mcp.tool()(get_context)
|
|
40
|
+
mcp.tool()(list_context)
|
|
41
|
+
mcp.tool()(clear_context)
|
|
42
|
+
mcp.tool()(extract_json_value)
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Fase 5b: Report generation
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
mcp.tool()(save_security_finding)
|
|
49
|
+
mcp.tool()(generate_report)
|
|
50
|
+
mcp.tool()(export_report_json)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for CLI argument parsing — verify all flags and defaults work correctly.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from mcp_api_logic_pentest.adapters.cli import build_parser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestCLIDefaults:
|
|
9
|
+
def test_default_delay(self):
|
|
10
|
+
parser = build_parser()
|
|
11
|
+
args = parser.parse_args([])
|
|
12
|
+
assert args.delay == 0.5
|
|
13
|
+
assert args.mock is False
|
|
14
|
+
assert args.output == "REPORTE_PENTEST.md"
|
|
15
|
+
|
|
16
|
+
def test_default_tokens_none(self):
|
|
17
|
+
parser = build_parser()
|
|
18
|
+
args = parser.parse_args([])
|
|
19
|
+
assert args.token_admin is None
|
|
20
|
+
assert args.token_victim is None
|
|
21
|
+
assert args.token_attacker is None
|
|
22
|
+
|
|
23
|
+
def test_default_config_none(self):
|
|
24
|
+
parser = build_parser()
|
|
25
|
+
args = parser.parse_args([])
|
|
26
|
+
assert args.config is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestCLIFlags:
|
|
30
|
+
def test_swagger_short_flag(self):
|
|
31
|
+
parser = build_parser()
|
|
32
|
+
args = parser.parse_args(["-s", "spec.json"])
|
|
33
|
+
assert args.swagger == "spec.json"
|
|
34
|
+
|
|
35
|
+
def test_target_short_flag(self):
|
|
36
|
+
parser = build_parser()
|
|
37
|
+
args = parser.parse_args(["-t", "https://api.example.com"])
|
|
38
|
+
assert args.target == "https://api.example.com"
|
|
39
|
+
|
|
40
|
+
def test_output_long_flag(self):
|
|
41
|
+
parser = build_parser()
|
|
42
|
+
args = parser.parse_args(["--output", "custom_report.md"])
|
|
43
|
+
assert args.output == "custom_report.md"
|
|
44
|
+
|
|
45
|
+
def test_output_short_flag(self):
|
|
46
|
+
parser = build_parser()
|
|
47
|
+
args = parser.parse_args(["-o", "short_report.md"])
|
|
48
|
+
assert args.output == "short_report.md"
|
|
49
|
+
|
|
50
|
+
def test_delay_flag(self):
|
|
51
|
+
parser = build_parser()
|
|
52
|
+
args = parser.parse_args(["--delay", "1.5"])
|
|
53
|
+
assert args.delay == 1.5
|
|
54
|
+
|
|
55
|
+
def test_mock_flag(self):
|
|
56
|
+
parser = build_parser()
|
|
57
|
+
args = parser.parse_args(["--mock"])
|
|
58
|
+
assert args.mock is True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestTokenFlags:
|
|
62
|
+
def test_admin_token_flag(self):
|
|
63
|
+
parser = build_parser()
|
|
64
|
+
args = parser.parse_args(["--token-admin", "admin_secret_2026"])
|
|
65
|
+
assert args.token_admin == "admin_secret_2026"
|
|
66
|
+
|
|
67
|
+
def test_victim_token_flag(self):
|
|
68
|
+
parser = build_parser()
|
|
69
|
+
args = parser.parse_args(["--token-victim", "victim_token"])
|
|
70
|
+
assert args.token_victim == "victim_token"
|
|
71
|
+
|
|
72
|
+
def test_attacker_token_flag(self):
|
|
73
|
+
parser = build_parser()
|
|
74
|
+
args = parser.parse_args(["--token-attacker", "attacker_token"])
|
|
75
|
+
assert args.token_attacker == "attacker_token"
|
|
76
|
+
|
|
77
|
+
def test_all_tokens_together(self):
|
|
78
|
+
parser = build_parser()
|
|
79
|
+
args = parser.parse_args([
|
|
80
|
+
"--token-admin", "admin",
|
|
81
|
+
"--token-victim", "victim",
|
|
82
|
+
"--token-attacker", "attacker",
|
|
83
|
+
])
|
|
84
|
+
assert args.token_admin == "admin"
|
|
85
|
+
assert args.token_victim == "victim"
|
|
86
|
+
assert args.token_attacker == "attacker"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestConfigFlag:
|
|
90
|
+
def test_config_flag(self):
|
|
91
|
+
parser = build_parser()
|
|
92
|
+
args = parser.parse_args(["--config", "config.yaml"])
|
|
93
|
+
assert args.config == "config.yaml"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestCombinedFlags:
|
|
97
|
+
def test_full_pentest_command(self):
|
|
98
|
+
parser = build_parser()
|
|
99
|
+
args = parser.parse_args([
|
|
100
|
+
"--swagger", "spec.json",
|
|
101
|
+
"--target", "https://api.example.com",
|
|
102
|
+
"--output", "report.md",
|
|
103
|
+
"--delay", "1.0",
|
|
104
|
+
"--token-admin", "admin123",
|
|
105
|
+
"--token-victim", "victim456",
|
|
106
|
+
"--token-attacker", "attacker789",
|
|
107
|
+
"--config", "settings.yaml",
|
|
108
|
+
])
|
|
109
|
+
assert args.swagger == "spec.json"
|
|
110
|
+
assert args.target == "https://api.example.com"
|
|
111
|
+
assert args.output == "report.md"
|
|
112
|
+
assert args.delay == 1.0
|
|
113
|
+
assert args.token_admin == "admin123"
|
|
114
|
+
assert args.token_victim == "victim456"
|
|
115
|
+
assert args.token_attacker == "attacker789"
|
|
116
|
+
assert args.config == "settings.yaml"
|
|
117
|
+
|
|
118
|
+
def test_mock_with_custom_delay(self):
|
|
119
|
+
parser = build_parser()
|
|
120
|
+
args = parser.parse_args(["--mock", "--delay", "2.0"])
|
|
121
|
+
assert args.mock is True
|
|
122
|
+
assert args.delay == 2.0
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
ATTACK_CONTEXT: Dict[str, str] = {}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def capture_context(key: str, value: str) -> str:
|
|
8
|
+
"""Stores a dynamic value in the session cache. Use after POST/PUT responses to save
|
|
9
|
+
resource IDs for later use in DELETE/GET requests with a different auth token."""
|
|
10
|
+
ATTACK_CONTEXT[key] = value
|
|
11
|
+
return json.dumps({
|
|
12
|
+
"status": "stored",
|
|
13
|
+
"key": key,
|
|
14
|
+
"value": value,
|
|
15
|
+
"total_keys": len(ATTACK_CONTEXT),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_context(key: str) -> str:
|
|
20
|
+
"""Retrieves a previously stored value from the session cache. Returns not_found
|
|
21
|
+
if the key does not exist. Use to inject dynamic IDs into attack URLs."""
|
|
22
|
+
value = ATTACK_CONTEXT.get(key)
|
|
23
|
+
if value is None:
|
|
24
|
+
return json.dumps({
|
|
25
|
+
"status": "not_found",
|
|
26
|
+
"key": key,
|
|
27
|
+
"available_keys": list(ATTACK_CONTEXT.keys()),
|
|
28
|
+
})
|
|
29
|
+
return json.dumps({
|
|
30
|
+
"status": "found",
|
|
31
|
+
"key": key,
|
|
32
|
+
"value": value,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_context() -> str:
|
|
37
|
+
"""Returns all key-value pairs stored in the session cache. Useful for the LLM
|
|
38
|
+
to see what dynamic IDs are available for chained attack flows."""
|
|
39
|
+
return json.dumps({
|
|
40
|
+
"total_keys": len(ATTACK_CONTEXT),
|
|
41
|
+
"entries": dict(ATTACK_CONTEXT),
|
|
42
|
+
}, indent=2)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def clear_context() -> str:
|
|
46
|
+
"""Clears all stored values from the session cache. Use at the start of a new audit
|
|
47
|
+
cycle to avoid stale data contamination."""
|
|
48
|
+
count = len(ATTACK_CONTEXT)
|
|
49
|
+
ATTACK_CONTEXT.clear()
|
|
50
|
+
return json.dumps({
|
|
51
|
+
"status": "cleared",
|
|
52
|
+
"keys_removed": count,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extract_json_value(response_json: str, json_path: str) -> str:
|
|
57
|
+
"""Extracts a specific value from a JSON response using dot-notation path.
|
|
58
|
+
Essential for chained attacks where the LLM needs to grab a dynamically
|
|
59
|
+
created resource ID from a POST response (e.g. 'data.id' → 42)."""
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(response_json) if isinstance(response_json, str) else response_json
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
return json.dumps({
|
|
64
|
+
"error": "Invalid JSON input",
|
|
65
|
+
"input_preview": response_json[:200],
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
parts = json_path.split(".")
|
|
69
|
+
current = data
|
|
70
|
+
traversed = []
|
|
71
|
+
|
|
72
|
+
for part in parts:
|
|
73
|
+
traversed.append(part)
|
|
74
|
+
if isinstance(current, dict):
|
|
75
|
+
if part not in current:
|
|
76
|
+
return json.dumps({
|
|
77
|
+
"error": f"Key '{part}' not found at path '{'.'.join(traversed)}'",
|
|
78
|
+
"available_keys": list(current.keys()) if isinstance(current, dict) else None,
|
|
79
|
+
})
|
|
80
|
+
current = current[part]
|
|
81
|
+
elif isinstance(current, list):
|
|
82
|
+
try:
|
|
83
|
+
idx = int(part)
|
|
84
|
+
current = current[idx]
|
|
85
|
+
except (ValueError, IndexError):
|
|
86
|
+
return json.dumps({
|
|
87
|
+
"error": f"Invalid list index '{part}' at path '{'.'.join(traversed)}'",
|
|
88
|
+
"list_length": len(current),
|
|
89
|
+
})
|
|
90
|
+
else:
|
|
91
|
+
return json.dumps({
|
|
92
|
+
"error": f"Cannot traverse into scalar value at path '{'.'.join(traversed[:-1])}'",
|
|
93
|
+
"scalar_value": str(current),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return json.dumps({
|
|
97
|
+
"status": "extracted",
|
|
98
|
+
"path": json_path,
|
|
99
|
+
"value": current,
|
|
100
|
+
"type": type(current).__name__,
|
|
101
|
+
}, default=str)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Test package
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for audit_context.attack_context module.
|
|
3
|
+
Uses autouse fixture to isolate ATTACK_CONTEXT state between tests.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from mcp_api_logic_pentest.audit_context.attack_context import (
|
|
10
|
+
ATTACK_CONTEXT,
|
|
11
|
+
capture_context,
|
|
12
|
+
clear_context,
|
|
13
|
+
extract_json_value,
|
|
14
|
+
get_context,
|
|
15
|
+
list_context,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(autouse=True)
|
|
20
|
+
def _clear_state():
|
|
21
|
+
ATTACK_CONTEXT.clear()
|
|
22
|
+
yield
|
|
23
|
+
ATTACK_CONTEXT.clear()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestCaptureContext:
|
|
27
|
+
def test_returns_stored_status(self):
|
|
28
|
+
result = json.loads(capture_context("user_id", "42"))
|
|
29
|
+
assert result["status"] == "stored"
|
|
30
|
+
assert result["key"] == "user_id"
|
|
31
|
+
assert result["value"] == "42"
|
|
32
|
+
|
|
33
|
+
def test_increments_total_keys(self):
|
|
34
|
+
capture_context("a", "1")
|
|
35
|
+
result = json.loads(capture_context("b", "2"))
|
|
36
|
+
assert result["total_keys"] == 2
|
|
37
|
+
|
|
38
|
+
def test_overwrites_existing_key(self):
|
|
39
|
+
capture_context("key", "old")
|
|
40
|
+
capture_context("key", "new")
|
|
41
|
+
assert ATTACK_CONTEXT["key"] == "new"
|
|
42
|
+
assert len(ATTACK_CONTEXT) == 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestGetContext:
|
|
46
|
+
def test_returns_value_for_existing_key(self):
|
|
47
|
+
capture_context("invoice_id", "1004")
|
|
48
|
+
result = json.loads(get_context("invoice_id"))
|
|
49
|
+
assert result["status"] == "found"
|
|
50
|
+
assert result["key"] == "invoice_id"
|
|
51
|
+
assert result["value"] == "1004"
|
|
52
|
+
|
|
53
|
+
def test_returns_not_found_for_missing_key(self):
|
|
54
|
+
result = json.loads(get_context("nonexistent"))
|
|
55
|
+
assert result["status"] == "not_found"
|
|
56
|
+
assert result["available_keys"] == []
|
|
57
|
+
|
|
58
|
+
def test_lists_available_keys_when_not_found(self):
|
|
59
|
+
capture_context("a", "1")
|
|
60
|
+
capture_context("b", "2")
|
|
61
|
+
result = json.loads(get_context("c"))
|
|
62
|
+
assert result["status"] == "not_found"
|
|
63
|
+
assert set(result["available_keys"]) == {"a", "b"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestListContext:
|
|
67
|
+
def test_returns_empty_when_no_keys(self):
|
|
68
|
+
result = json.loads(list_context())
|
|
69
|
+
assert result["total_keys"] == 0
|
|
70
|
+
assert result["entries"] == {}
|
|
71
|
+
|
|
72
|
+
def test_returns_all_stored_pairs(self):
|
|
73
|
+
capture_context("id", "42")
|
|
74
|
+
capture_context("token", "abc")
|
|
75
|
+
result = json.loads(list_context())
|
|
76
|
+
assert result["total_keys"] == 2
|
|
77
|
+
assert result["entries"]["id"] == "42"
|
|
78
|
+
assert result["entries"]["token"] == "abc"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestClearContext:
|
|
82
|
+
def test_clears_all_entries(self):
|
|
83
|
+
capture_context("a", "1")
|
|
84
|
+
capture_context("b", "2")
|
|
85
|
+
result = json.loads(clear_context())
|
|
86
|
+
assert result["status"] == "cleared"
|
|
87
|
+
assert result["keys_removed"] == 2
|
|
88
|
+
assert len(ATTACK_CONTEXT) == 0
|
|
89
|
+
|
|
90
|
+
def test_returns_zero_when_already_empty(self):
|
|
91
|
+
result = json.loads(clear_context())
|
|
92
|
+
assert result["status"] == "cleared"
|
|
93
|
+
assert result["keys_removed"] == 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestExtractJsonValue:
|
|
97
|
+
def test_extracts_simple_key(self):
|
|
98
|
+
result = json.loads(extract_json_value('{"data": {"id": 42}}', "data.id"))
|
|
99
|
+
assert result["status"] == "extracted"
|
|
100
|
+
assert result["value"] == 42
|
|
101
|
+
assert result["type"] == "int"
|
|
102
|
+
|
|
103
|
+
def test_extracts_from_list_index(self):
|
|
104
|
+
result = json.loads(extract_json_value('{"items": [10, 20, 30]}', "items.1"))
|
|
105
|
+
assert result["status"] == "extracted"
|
|
106
|
+
assert result["value"] == 20
|
|
107
|
+
|
|
108
|
+
def test_extracts_nested_dict_path(self):
|
|
109
|
+
result = json.loads(extract_json_value(
|
|
110
|
+
'{"results": [{"name": "first"}, {"name": "second"}]}',
|
|
111
|
+
"results.0.name"
|
|
112
|
+
))
|
|
113
|
+
assert result["value"] == "first"
|
|
114
|
+
|
|
115
|
+
def test_returns_error_for_invalid_json(self):
|
|
116
|
+
result = json.loads(extract_json_value("not valid json", "key"))
|
|
117
|
+
assert "error" in result
|
|
118
|
+
assert "Invalid JSON" in result["error"]
|
|
119
|
+
|
|
120
|
+
def test_returns_error_for_missing_key(self):
|
|
121
|
+
result = json.loads(extract_json_value('{"a": 1}', "b"))
|
|
122
|
+
assert "error" in result
|
|
123
|
+
assert "not found" in result["error"]
|
|
124
|
+
assert result["available_keys"] == ["a"]
|
|
125
|
+
|
|
126
|
+
def test_returns_error_for_invalid_list_index(self):
|
|
127
|
+
result = json.loads(extract_json_value('{"items": [1, 2]}', "items.5"))
|
|
128
|
+
assert "error" in result
|
|
129
|
+
assert "Invalid list index" in result["error"]
|
|
130
|
+
|
|
131
|
+
def test_returns_error_for_traverse_into_scalar(self):
|
|
132
|
+
result = json.loads(extract_json_value('{"value": 42}', "value.nested"))
|
|
133
|
+
assert "error" in result
|
|
134
|
+
assert "scalar" in result["error"]
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
MAX_RESPONSE_SIZE_BYTES = 4096
|
|
4
|
+
DEFAULT_DELAY_SECONDS = 0.5
|
|
5
|
+
DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
6
|
+
|
|
7
|
+
USER_AGENT = (
|
|
8
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
9
|
+
"mcp-api-logic-pentest/1.0"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
logging.basicConfig(level=logging.INFO)
|
|
13
|
+
logger = logging.getLogger("mcp-api-logic-pentest")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Dict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def apply_bearer_auth(headers: Dict[str, str], token: str) -> Dict[str, str]:
|
|
5
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
6
|
+
return headers
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_api_key_header(headers: Dict[str, str], key_name: str, key_value: str) -> Dict[str, str]:
|
|
10
|
+
headers[key_name] = key_value
|
|
11
|
+
return headers
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def apply_basic_auth(headers: Dict[str, str], username: str, password: str) -> Dict[str, str]:
|
|
15
|
+
import base64
|
|
16
|
+
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
17
|
+
headers["Authorization"] = f"Basic {credentials}"
|
|
18
|
+
return headers
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from mcp_api_logic_pentest.config.settings import (
|
|
8
|
+
DEFAULT_DELAY_SECONDS,
|
|
9
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
10
|
+
MAX_RESPONSE_SIZE_BYTES,
|
|
11
|
+
USER_AGENT,
|
|
12
|
+
logger,
|
|
13
|
+
)
|
|
14
|
+
from mcp_api_logic_pentest.shared.json_truncator import truncate_json_intelligently
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def execute_security_request(
|
|
18
|
+
url: str,
|
|
19
|
+
method: str,
|
|
20
|
+
auth_token: Optional[str] = None,
|
|
21
|
+
body_json: Optional[str] = None,
|
|
22
|
+
delay_seconds: float = DEFAULT_DELAY_SECONDS,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Async resilient HTTP client with anti-blocking (WAF) protection and passive rate limiting.
|
|
26
|
+
Sends LLM-directed requests and returns a structured, token-optimized traffic report.
|
|
27
|
+
"""
|
|
28
|
+
if delay_seconds > 0:
|
|
29
|
+
time.sleep(delay_seconds)
|
|
30
|
+
|
|
31
|
+
method = method.upper()
|
|
32
|
+
headers = {
|
|
33
|
+
"User-Agent": USER_AGENT,
|
|
34
|
+
"Accept": "application/json",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if auth_token:
|
|
38
|
+
headers["Authorization"] = f"Bearer {auth_token}"
|
|
39
|
+
if body_json:
|
|
40
|
+
headers["Content-Type"] = "application/json"
|
|
41
|
+
|
|
42
|
+
payload = None
|
|
43
|
+
if body_json:
|
|
44
|
+
try:
|
|
45
|
+
payload = json.loads(body_json)
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
return "Error: The 'body_json' parameter supplied is not valid JSON."
|
|
48
|
+
|
|
49
|
+
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT_SECONDS) as client:
|
|
50
|
+
try:
|
|
51
|
+
logger.info(f"Executing audit request: {method} {url}")
|
|
52
|
+
response = await client.request(
|
|
53
|
+
method=method,
|
|
54
|
+
url=url,
|
|
55
|
+
headers=headers,
|
|
56
|
+
json=payload,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
report = {
|
|
60
|
+
"status_code": response.status_code,
|
|
61
|
+
"response_headers": dict(response.headers),
|
|
62
|
+
"raw_size_bytes": len(response.text),
|
|
63
|
+
"response_body": None,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if "application/json" in response.headers.get("content-type", "").lower():
|
|
67
|
+
try:
|
|
68
|
+
json_data = response.json()
|
|
69
|
+
if len(response.text) > MAX_RESPONSE_SIZE_BYTES:
|
|
70
|
+
report["response_body"] = truncate_json_intelligently(json_data)
|
|
71
|
+
else:
|
|
72
|
+
report["response_body"] = json_data
|
|
73
|
+
except Exception:
|
|
74
|
+
report["response_body"] = response.text[:500] + " ... [Unparseable JSON truncated]"
|
|
75
|
+
else:
|
|
76
|
+
report["response_body"] = response.text[:1000] + "\n... [Non-JSON text truncated by MCP server]"
|
|
77
|
+
|
|
78
|
+
return json.dumps(report, indent=2, ensure_ascii=False)
|
|
79
|
+
|
|
80
|
+
except httpx.RequestError as exc:
|
|
81
|
+
return json.dumps({
|
|
82
|
+
"error": "Network connection failure to target",
|
|
83
|
+
"details": f"Could not connect to the web server: {str(exc)}",
|
|
84
|
+
})
|