crowdsec-local-mcp 0.0.2__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.
- crowdsec_local_mcp/__init__.py +5 -0
- crowdsec_local_mcp/__main__.py +24 -0
- crowdsec_local_mcp/compose/waf-test/.gitignore +3 -0
- crowdsec_local_mcp/compose/waf-test/crowdsec/acquis.d/appsec.yaml +8 -0
- crowdsec_local_mcp/compose/waf-test/crowdsec/appsec-configs/mcp-appsec.yaml.template +8 -0
- crowdsec_local_mcp/compose/waf-test/crowdsec/init-bouncer.sh +29 -0
- crowdsec_local_mcp/compose/waf-test/docker-compose.yml +68 -0
- crowdsec_local_mcp/compose/waf-test/nginx/Dockerfile +67 -0
- crowdsec_local_mcp/compose/waf-test/nginx/crowdsec/crowdsec-openresty-bouncer.conf +25 -0
- crowdsec_local_mcp/compose/waf-test/nginx/nginx.conf +25 -0
- crowdsec_local_mcp/compose/waf-test/nginx/site-enabled/default-site.conf +15 -0
- crowdsec_local_mcp/compose/waf-test/rules/.gitkeep +0 -0
- crowdsec_local_mcp/compose/waf-test/rules/base-config.yaml +11 -0
- crowdsec_local_mcp/mcp_core.py +151 -0
- crowdsec_local_mcp/mcp_scenarios.py +380 -0
- crowdsec_local_mcp/mcp_waf.py +1170 -0
- crowdsec_local_mcp/prompts/prompt-scenario-deploy.txt +27 -0
- crowdsec_local_mcp/prompts/prompt-scenario-examples.txt +237 -0
- crowdsec_local_mcp/prompts/prompt-scenario.txt +84 -0
- crowdsec_local_mcp/prompts/prompt-waf-deploy.txt +118 -0
- crowdsec_local_mcp/prompts/prompt-waf-examples.txt +401 -0
- crowdsec_local_mcp/prompts/prompt-waf.txt +343 -0
- crowdsec_local_mcp/setup_cli.py +306 -0
- crowdsec_local_mcp/yaml-schemas/appsec_rules_schema.yaml +343 -0
- crowdsec_local_mcp/yaml-schemas/scenario_schema.yaml +591 -0
- crowdsec_local_mcp-0.0.2.dist-info/METADATA +74 -0
- crowdsec_local_mcp-0.0.2.dist-info/RECORD +31 -0
- crowdsec_local_mcp-0.0.2.dist-info/WHEEL +5 -0
- crowdsec_local_mcp-0.0.2.dist-info/entry_points.txt +3 -0
- crowdsec_local_mcp-0.0.2.dist-info/licenses/LICENSE +21 -0
- crowdsec_local_mcp-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import time
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import jsonschema
|
|
8
|
+
import requests
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
import mcp.types as types
|
|
12
|
+
|
|
13
|
+
from .mcp_core import LOGGER, PROMPTS_DIR, REGISTRY, SCRIPT_DIR, ToolHandler
|
|
14
|
+
|
|
15
|
+
WAF_PROMPT_FILE = PROMPTS_DIR / "prompt-waf.txt"
|
|
16
|
+
WAF_EXAMPLES_FILE = PROMPTS_DIR / "prompt-waf-examples.txt"
|
|
17
|
+
WAF_DEPLOY_FILE = PROMPTS_DIR / "prompt-waf-deploy.txt"
|
|
18
|
+
|
|
19
|
+
CROWDSEC_SCHEMAS_DIR = SCRIPT_DIR / "yaml-schemas"
|
|
20
|
+
WAF_SCHEMA_FILE = CROWDSEC_SCHEMAS_DIR / "appsec_rules_schema.yaml"
|
|
21
|
+
|
|
22
|
+
WAF_TEST_COMPOSE_DIR = SCRIPT_DIR / "compose" / "waf-test"
|
|
23
|
+
WAF_TEST_COMPOSE_FILE = WAF_TEST_COMPOSE_DIR / "docker-compose.yml"
|
|
24
|
+
WAF_TEST_RULE_PATH = WAF_TEST_COMPOSE_DIR / "rules" / "current-rule.yaml"
|
|
25
|
+
WAF_TEST_APPSEC_TEMPLATE = (
|
|
26
|
+
WAF_TEST_COMPOSE_DIR
|
|
27
|
+
/ "crowdsec"
|
|
28
|
+
/ "appsec-configs"
|
|
29
|
+
/ "mcp-appsec.yaml.template"
|
|
30
|
+
)
|
|
31
|
+
WAF_TEST_APPSEC_CONFIG = (
|
|
32
|
+
WAF_TEST_COMPOSE_DIR
|
|
33
|
+
/ "crowdsec"
|
|
34
|
+
/ "appsec-configs"
|
|
35
|
+
/ "mcp-appsec.yaml"
|
|
36
|
+
)
|
|
37
|
+
WAF_RULE_NAME_PLACEHOLDER = "__PLACEHOLDER_FOR_USER_RULE__"
|
|
38
|
+
WAF_TEST_PROJECT_NAME = "crowdsec-mcp-waf"
|
|
39
|
+
|
|
40
|
+
DEFAULT_EXPLOIT_REPOSITORIES = [
|
|
41
|
+
"https://github.com/projectdiscovery/nuclei-templates.git",
|
|
42
|
+
]
|
|
43
|
+
DEFAULT_EXPLOIT_TARGET_DIR = SCRIPT_DIR / "cached-exploits"
|
|
44
|
+
|
|
45
|
+
CASE_SENSITIVE_MATCH_TYPES = ["regex", "contains", "startsWith", "endsWith", "equals"]
|
|
46
|
+
SQL_KEYWORD_INDICATORS = ["union", "select", "insert", "update", "delete", "drop"]
|
|
47
|
+
|
|
48
|
+
_COMPOSE_CMD_CACHE: Optional[List[str]] = None
|
|
49
|
+
_COMPOSE_STACK_PROCESS: Optional[subprocess.Popen] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _detect_compose_command() -> List[str]:
|
|
53
|
+
"""Detect whether docker compose or docker-compose is available."""
|
|
54
|
+
global _COMPOSE_CMD_CACHE
|
|
55
|
+
if _COMPOSE_CMD_CACHE is not None:
|
|
56
|
+
return _COMPOSE_CMD_CACHE
|
|
57
|
+
|
|
58
|
+
candidates = [["docker", "compose"], ["docker-compose"]]
|
|
59
|
+
|
|
60
|
+
for candidate in candidates:
|
|
61
|
+
try:
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
candidate + ["version"],
|
|
64
|
+
check=True,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
)
|
|
68
|
+
if result.returncode == 0:
|
|
69
|
+
_COMPOSE_CMD_CACHE = candidate
|
|
70
|
+
LOGGER.info("Detected compose command: %s", " ".join(candidate))
|
|
71
|
+
return candidate
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
continue
|
|
74
|
+
except subprocess.CalledProcessError:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
LOGGER.error(
|
|
78
|
+
"Failed to detect Docker Compose command; ensure Docker is installed and available"
|
|
79
|
+
)
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"Docker Compose is required but was not found. Install Docker and ensure `docker compose` or `docker-compose` is available."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _collect_compose_logs(services: Optional[List[str]] = None, tail_lines: int = 200) -> str:
|
|
86
|
+
cmd = _detect_compose_command() + [
|
|
87
|
+
"-p",
|
|
88
|
+
WAF_TEST_PROJECT_NAME,
|
|
89
|
+
"-f",
|
|
90
|
+
str(WAF_TEST_COMPOSE_FILE),
|
|
91
|
+
"logs",
|
|
92
|
+
]
|
|
93
|
+
if services:
|
|
94
|
+
cmd.extend(services)
|
|
95
|
+
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
cmd,
|
|
98
|
+
cwd=str(WAF_TEST_COMPOSE_DIR),
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
check=False,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
combined = "\n".join(
|
|
105
|
+
part.strip()
|
|
106
|
+
for part in ((result.stdout or ""), (result.stderr or ""))
|
|
107
|
+
if part
|
|
108
|
+
).strip()
|
|
109
|
+
|
|
110
|
+
if not combined:
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
lines = combined.splitlines()
|
|
114
|
+
if tail_lines and len(lines) > tail_lines:
|
|
115
|
+
lines = lines[-tail_lines:]
|
|
116
|
+
lines.insert(0, f"(showing last {tail_lines} lines)")
|
|
117
|
+
return "\n".join(lines)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_compose_command(
|
|
121
|
+
args: List[str], capture_output: bool = True, check: bool = True
|
|
122
|
+
) -> subprocess.CompletedProcess:
|
|
123
|
+
"""Run a docker compose command inside the WAF test harness directory."""
|
|
124
|
+
base_cmd = _detect_compose_command()
|
|
125
|
+
full_cmd = base_cmd + ["-p", WAF_TEST_PROJECT_NAME, "-f", str(WAF_TEST_COMPOSE_FILE)] + args
|
|
126
|
+
LOGGER.info("Executing compose command: %s", " ".join(full_cmd))
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
return subprocess.run(
|
|
130
|
+
full_cmd,
|
|
131
|
+
cwd=str(WAF_TEST_COMPOSE_DIR),
|
|
132
|
+
check=check,
|
|
133
|
+
capture_output=capture_output,
|
|
134
|
+
text=True,
|
|
135
|
+
)
|
|
136
|
+
except FileNotFoundError as error:
|
|
137
|
+
LOGGER.error("Compose command failed to start: %s", error)
|
|
138
|
+
raise RuntimeError(f"Failed to run {' '.join(base_cmd)}: {error}") from error
|
|
139
|
+
except subprocess.CalledProcessError as error:
|
|
140
|
+
stdout = (error.stdout or "").strip()
|
|
141
|
+
stderr = (error.stderr or "").strip()
|
|
142
|
+
combined = "\n".join(part for part in (stdout, stderr) if part)
|
|
143
|
+
if not combined:
|
|
144
|
+
combined = str(error)
|
|
145
|
+
LOGGER.error(
|
|
146
|
+
"Compose command exited with %s: %s",
|
|
147
|
+
error.returncode,
|
|
148
|
+
combined.splitlines()[0] if combined else "no output",
|
|
149
|
+
)
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"docker compose {' '.join(args)} failed (exit code {error.returncode}):\n{combined}"
|
|
152
|
+
) from error
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _run_compose_exec(
|
|
156
|
+
args: List[str], capture_output: bool = True, check: bool = True
|
|
157
|
+
) -> subprocess.CompletedProcess:
|
|
158
|
+
"""Run docker compose exec against the CrowdSec container."""
|
|
159
|
+
exec_args = ["exec", "-T"] + args
|
|
160
|
+
return _run_compose_command(exec_args, capture_output=capture_output, check=check)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _teardown_compose_stack(check: bool = True) -> None:
|
|
164
|
+
"""Stop the compose stack and ensure any supervising process is terminated."""
|
|
165
|
+
global _COMPOSE_STACK_PROCESS
|
|
166
|
+
if not WAF_TEST_COMPOSE_FILE.exists():
|
|
167
|
+
LOGGER.warning(
|
|
168
|
+
"Requested stack teardown but compose file %s is missing", WAF_TEST_COMPOSE_FILE
|
|
169
|
+
)
|
|
170
|
+
_COMPOSE_STACK_PROCESS = None
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
LOGGER.info("Stopping WAF test stack")
|
|
174
|
+
try:
|
|
175
|
+
_run_compose_command(["down"], check=check)
|
|
176
|
+
finally:
|
|
177
|
+
if _COMPOSE_STACK_PROCESS is not None:
|
|
178
|
+
try:
|
|
179
|
+
_COMPOSE_STACK_PROCESS.wait(timeout=15)
|
|
180
|
+
except subprocess.TimeoutExpired:
|
|
181
|
+
LOGGER.warning(
|
|
182
|
+
"Compose stack process did not exit in time; terminating forcefully"
|
|
183
|
+
)
|
|
184
|
+
_COMPOSE_STACK_PROCESS.kill()
|
|
185
|
+
_COMPOSE_STACK_PROCESS.wait(timeout=5)
|
|
186
|
+
_COMPOSE_STACK_PROCESS = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _wait_for_crowdsec_ready(timeout: int = 90) -> None:
|
|
190
|
+
"""Wait until the CrowdSec local API is reachable."""
|
|
191
|
+
global _COMPOSE_STACK_PROCESS
|
|
192
|
+
LOGGER.info("Waiting for CrowdSec API to become ready (timeout=%s)", timeout)
|
|
193
|
+
deadline = time.time() + timeout
|
|
194
|
+
while time.time() < deadline:
|
|
195
|
+
if _COMPOSE_STACK_PROCESS is not None:
|
|
196
|
+
exit_code = _COMPOSE_STACK_PROCESS.poll()
|
|
197
|
+
if exit_code is not None:
|
|
198
|
+
_COMPOSE_STACK_PROCESS = None
|
|
199
|
+
logs = _collect_compose_logs(["crowdsec", "nginx", "backend"])
|
|
200
|
+
log_section = f"\n\nService logs:\n{logs}" if logs else ""
|
|
201
|
+
raise RuntimeError(
|
|
202
|
+
"WAF stack exited while waiting for CrowdSec to become ready"
|
|
203
|
+
f" (exit code {exit_code}).{log_section}"
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
result = _run_compose_exec(
|
|
207
|
+
["crowdsec", "cscli", "lapi", "status"], capture_output=True, check=False
|
|
208
|
+
)
|
|
209
|
+
if isinstance(result, subprocess.CompletedProcess) and result.returncode == 0:
|
|
210
|
+
LOGGER.info("CrowdSec API is ready")
|
|
211
|
+
return
|
|
212
|
+
except RuntimeError:
|
|
213
|
+
pass
|
|
214
|
+
time.sleep(3)
|
|
215
|
+
|
|
216
|
+
LOGGER.error("CrowdSec API did not become ready before timeout")
|
|
217
|
+
raise RuntimeError("CrowdSec local API did not become ready in time")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _start_waf_test_stack(rule_yaml: str) -> Tuple[Optional[str], Optional[str]]:
|
|
221
|
+
global _COMPOSE_STACK_PROCESS
|
|
222
|
+
LOGGER.info("Starting WAF test stack")
|
|
223
|
+
if not WAF_TEST_COMPOSE_FILE.exists():
|
|
224
|
+
LOGGER.error("Compose file missing at %s", WAF_TEST_COMPOSE_FILE)
|
|
225
|
+
return (
|
|
226
|
+
None,
|
|
227
|
+
"Docker compose stack not found; expected compose/waf-test/docker-compose.yml",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
rule_metadata = yaml.safe_load(rule_yaml) or {}
|
|
232
|
+
except yaml.YAMLError as exc:
|
|
233
|
+
LOGGER.error("Failed to parse WAF rule YAML: %s", exc)
|
|
234
|
+
return (None, f"Cannot parse WAF rule YAML: {exc}")
|
|
235
|
+
|
|
236
|
+
if not isinstance(rule_metadata, dict):
|
|
237
|
+
return (None, "WAF rule YAML must define a top-level mapping")
|
|
238
|
+
|
|
239
|
+
rule_name = rule_metadata.get("name")
|
|
240
|
+
if not isinstance(rule_name, str) or not rule_name.strip():
|
|
241
|
+
LOGGER.warning("WAF rule YAML missing required 'name' field")
|
|
242
|
+
return (None, "WAF rule YAML must include a non-empty string 'name' field")
|
|
243
|
+
rule_name = rule_name.strip()
|
|
244
|
+
|
|
245
|
+
if not WAF_TEST_APPSEC_TEMPLATE.exists():
|
|
246
|
+
LOGGER.error("AppSec template missing at %s", WAF_TEST_APPSEC_TEMPLATE)
|
|
247
|
+
return (
|
|
248
|
+
None,
|
|
249
|
+
"AppSec config template not found; expected compose/waf-test/crowdsec/appsec-configs/mcp-appsec.yaml.template",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
template_content = WAF_TEST_APPSEC_TEMPLATE.read_text(encoding="utf-8")
|
|
253
|
+
if WAF_RULE_NAME_PLACEHOLDER not in template_content:
|
|
254
|
+
return (None, "AppSec config template missing rule name placeholder")
|
|
255
|
+
|
|
256
|
+
rendered_appsec_config = template_content.replace(WAF_RULE_NAME_PLACEHOLDER, rule_name)
|
|
257
|
+
|
|
258
|
+
WAF_TEST_COMPOSE_DIR.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
WAF_TEST_RULE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
WAF_TEST_RULE_PATH.write_text(rule_yaml, encoding="utf-8")
|
|
261
|
+
WAF_TEST_APPSEC_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
WAF_TEST_APPSEC_CONFIG.write_text(rendered_appsec_config, encoding="utf-8")
|
|
263
|
+
|
|
264
|
+
if _COMPOSE_STACK_PROCESS is not None:
|
|
265
|
+
if _COMPOSE_STACK_PROCESS.poll() is None:
|
|
266
|
+
LOGGER.warning("Stack start requested while previous stack still running")
|
|
267
|
+
return (
|
|
268
|
+
None,
|
|
269
|
+
"WAF test stack appears to be running already. Stop it before starting a new session.",
|
|
270
|
+
)
|
|
271
|
+
_COMPOSE_STACK_PROCESS = None
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
_run_compose_command(["up", "-d", "crowdsec"])
|
|
275
|
+
except RuntimeError as error:
|
|
276
|
+
LOGGER.error("Failed to start CrowdSec container: %s", error)
|
|
277
|
+
logs = _collect_compose_logs(["crowdsec"])
|
|
278
|
+
message = str(error)
|
|
279
|
+
if logs:
|
|
280
|
+
message = f"{message}\n\nCrowdSec logs:\n{logs}"
|
|
281
|
+
return (None, message)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
_wait_for_crowdsec_ready()
|
|
285
|
+
except RuntimeError as error:
|
|
286
|
+
LOGGER.error("CrowdSec failed readiness check: %s", error)
|
|
287
|
+
logs = _collect_compose_logs(["crowdsec"])
|
|
288
|
+
log_section = f"\n\nCrowdSec logs:\n{logs}" if logs else ""
|
|
289
|
+
_teardown_compose_stack(check=False)
|
|
290
|
+
return (None, f"{error}{log_section}")
|
|
291
|
+
|
|
292
|
+
compose_base = _detect_compose_command() + [
|
|
293
|
+
"-p",
|
|
294
|
+
WAF_TEST_PROJECT_NAME,
|
|
295
|
+
"-f",
|
|
296
|
+
str(WAF_TEST_COMPOSE_FILE),
|
|
297
|
+
"up",
|
|
298
|
+
"--build",
|
|
299
|
+
"--abort-on-container-exit",
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
process = subprocess.Popen(
|
|
304
|
+
compose_base + ["crowdsec", "nginx", "backend"],
|
|
305
|
+
cwd=str(WAF_TEST_COMPOSE_DIR),
|
|
306
|
+
stdout=subprocess.DEVNULL,
|
|
307
|
+
stderr=subprocess.STDOUT,
|
|
308
|
+
)
|
|
309
|
+
except FileNotFoundError:
|
|
310
|
+
LOGGER.error("Failed to launch docker compose process")
|
|
311
|
+
return (
|
|
312
|
+
None,
|
|
313
|
+
"Docker Compose is required but could not be executed. Ensure Docker is installed and available.",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
_COMPOSE_STACK_PROCESS = process
|
|
317
|
+
|
|
318
|
+
time.sleep(2)
|
|
319
|
+
immediate_exit = process.poll()
|
|
320
|
+
if immediate_exit is not None:
|
|
321
|
+
LOGGER.error("Compose process exited immediately with code %s", immediate_exit)
|
|
322
|
+
logs = _collect_compose_logs(["crowdsec", "nginx", "backend"])
|
|
323
|
+
log_section = f"\n\nService logs:\n{logs}" if logs else ""
|
|
324
|
+
_teardown_compose_stack(check=False)
|
|
325
|
+
return (
|
|
326
|
+
None,
|
|
327
|
+
f"docker compose up failed to start the stack (exit code {immediate_exit}).{log_section}",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
LOGGER.info("WAF test stack started successfully")
|
|
331
|
+
return ("http://localhost:8081", None)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _stop_waf_test_stack() -> None:
|
|
335
|
+
LOGGER.info("Stopping WAF test stack via tool request")
|
|
336
|
+
_teardown_compose_stack(check=True)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _validate_waf_rule(rule_yaml: str) -> List[types.TextContent]:
|
|
340
|
+
"""Validate that a CrowdSec WAF rule YAML conforms to the schema."""
|
|
341
|
+
LOGGER.info("Validating WAF rule YAML (size=%s bytes)", len(rule_yaml.encode("utf-8")))
|
|
342
|
+
try:
|
|
343
|
+
if not WAF_SCHEMA_FILE.exists():
|
|
344
|
+
LOGGER.error("Schema file missing at %s", WAF_SCHEMA_FILE)
|
|
345
|
+
return [
|
|
346
|
+
types.TextContent(
|
|
347
|
+
type="text",
|
|
348
|
+
text=f"❌ VALIDATION FAILED: Schema file {WAF_SCHEMA_FILE} not found",
|
|
349
|
+
)
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
schema = yaml.safe_load(WAF_SCHEMA_FILE.read_text(encoding="utf-8"))
|
|
353
|
+
parsed = yaml.safe_load(rule_yaml)
|
|
354
|
+
|
|
355
|
+
if parsed is None:
|
|
356
|
+
LOGGER.warning("Validation request received empty YAML content")
|
|
357
|
+
return [
|
|
358
|
+
types.TextContent(
|
|
359
|
+
type="text",
|
|
360
|
+
text="❌ VALIDATION FAILED: Empty or invalid YAML content",
|
|
361
|
+
)
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
if not isinstance(parsed, dict):
|
|
365
|
+
return [
|
|
366
|
+
types.TextContent(
|
|
367
|
+
type="text",
|
|
368
|
+
text="❌ VALIDATION FAILED: YAML must be a dictionary/object",
|
|
369
|
+
)
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
jsonschema.validate(instance=parsed, schema=schema)
|
|
373
|
+
|
|
374
|
+
LOGGER.info("WAF rule validation passed")
|
|
375
|
+
return [
|
|
376
|
+
types.TextContent(
|
|
377
|
+
type="text",
|
|
378
|
+
text="✅ VALIDATION PASSED: Rule conforms to CrowdSec AppSec schema",
|
|
379
|
+
)
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
except yaml.YAMLError as e:
|
|
383
|
+
LOGGER.error("YAML syntax error during validation: %s", e)
|
|
384
|
+
return [
|
|
385
|
+
types.TextContent(
|
|
386
|
+
type="text",
|
|
387
|
+
text=f"❌ VALIDATION FAILED: YAML syntax error: {str(e)}",
|
|
388
|
+
)
|
|
389
|
+
]
|
|
390
|
+
except jsonschema.ValidationError as e:
|
|
391
|
+
error_path = " -> ".join(str(p) for p in e.absolute_path) if e.absolute_path else "root"
|
|
392
|
+
LOGGER.warning("Schema validation error at %s: %s", error_path, e.message)
|
|
393
|
+
return [
|
|
394
|
+
types.TextContent(
|
|
395
|
+
type="text",
|
|
396
|
+
text=f"❌ VALIDATION FAILED: Schema validation error at {error_path}: {e.message}",
|
|
397
|
+
)
|
|
398
|
+
]
|
|
399
|
+
except jsonschema.SchemaError as e:
|
|
400
|
+
LOGGER.error("Invalid schema encountered: %s", e)
|
|
401
|
+
return [
|
|
402
|
+
types.TextContent(
|
|
403
|
+
type="text",
|
|
404
|
+
text=f"❌ VALIDATION FAILED: Invalid schema: {str(e)}",
|
|
405
|
+
)
|
|
406
|
+
]
|
|
407
|
+
except Exception as e:
|
|
408
|
+
LOGGER.error("Unexpected validation error: %s", e)
|
|
409
|
+
return [
|
|
410
|
+
types.TextContent(
|
|
411
|
+
type="text",
|
|
412
|
+
text=f"❌ VALIDATION FAILED: Unexpected error: {str(e)}",
|
|
413
|
+
)
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _lint_waf_rule(rule_yaml: str) -> List[types.TextContent]:
|
|
418
|
+
"""Lint a CrowdSec WAF rule and provide warnings/hints for improvement."""
|
|
419
|
+
LOGGER.info("Linting WAF rule YAML (size=%s bytes)", len(rule_yaml.encode("utf-8")))
|
|
420
|
+
try:
|
|
421
|
+
parsed = yaml.safe_load(rule_yaml)
|
|
422
|
+
|
|
423
|
+
if parsed is None:
|
|
424
|
+
LOGGER.warning("Lint request failed: YAML content was empty or invalid")
|
|
425
|
+
return [
|
|
426
|
+
types.TextContent(
|
|
427
|
+
type="text",
|
|
428
|
+
text="❌ LINT ERROR: Cannot lint empty or invalid YAML",
|
|
429
|
+
)
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
warnings: List[str] = []
|
|
433
|
+
hints: List[str] = []
|
|
434
|
+
|
|
435
|
+
if not isinstance(parsed, dict):
|
|
436
|
+
warnings.append("Rule should be a YAML dictionary")
|
|
437
|
+
|
|
438
|
+
if "name" not in parsed:
|
|
439
|
+
warnings.append("Missing 'name' field")
|
|
440
|
+
|
|
441
|
+
if "rules" not in parsed:
|
|
442
|
+
warnings.append("Missing 'rules' field")
|
|
443
|
+
|
|
444
|
+
if "labels" not in parsed:
|
|
445
|
+
warnings.append("Missing 'labels' field")
|
|
446
|
+
|
|
447
|
+
if "name" in parsed:
|
|
448
|
+
name = parsed.get("name", "")
|
|
449
|
+
if isinstance(name, str):
|
|
450
|
+
if name.startswith("crowdsecurity/"):
|
|
451
|
+
warnings.append(
|
|
452
|
+
"Rule name starts with 'crowdsecurity/' which is reserved for official CrowdSec rules; consider using your own namespace"
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
warnings.append("Field 'name' should be a string")
|
|
456
|
+
|
|
457
|
+
def check_rule_item(rule_item: Any, rule_path: str = "") -> None:
|
|
458
|
+
"""Recursively check rule items for case sensitivity issues."""
|
|
459
|
+
if not isinstance(rule_item, dict):
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
if "and" in rule_item:
|
|
463
|
+
for i, sub_rule in enumerate(rule_item["and"]):
|
|
464
|
+
check_rule_item(sub_rule, f"{rule_path}.and[{i}]")
|
|
465
|
+
elif "or" in rule_item:
|
|
466
|
+
for i, sub_rule in enumerate(rule_item["or"]):
|
|
467
|
+
check_rule_item(sub_rule, f"{rule_path}.or[{i}]")
|
|
468
|
+
elif "match" in rule_item:
|
|
469
|
+
match = rule_item["match"]
|
|
470
|
+
if isinstance(match, dict):
|
|
471
|
+
match_type = match.get("type", "")
|
|
472
|
+
match_value = match.get("value", "")
|
|
473
|
+
|
|
474
|
+
if (
|
|
475
|
+
match_type in CASE_SENSITIVE_MATCH_TYPES
|
|
476
|
+
and isinstance(match_value, str)
|
|
477
|
+
and any(c.isupper() for c in match_value)
|
|
478
|
+
):
|
|
479
|
+
transforms = rule_item.get("transform", [])
|
|
480
|
+
has_lowercase = (
|
|
481
|
+
"lowercase" in transforms if isinstance(transforms, list) else False
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if not has_lowercase:
|
|
485
|
+
location = f"rules{rule_path}" if rule_path else "rules"
|
|
486
|
+
warnings.append(
|
|
487
|
+
f"Match at {location} uses '{match_type}' with uppercase letters "
|
|
488
|
+
f"but no 'lowercase' transform - consider adding lowercase transform for case-insensitive matching"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if isinstance(match_value, str):
|
|
492
|
+
lower_value = match_value.lower()
|
|
493
|
+
sql_keywords = [kw for kw in SQL_KEYWORD_INDICATORS if kw in lower_value]
|
|
494
|
+
if sql_keywords:
|
|
495
|
+
location = f"rules{rule_path}" if rule_path else "rules"
|
|
496
|
+
keywords_str = ", ".join(sorted(set(sql_keywords)))
|
|
497
|
+
warnings.append(
|
|
498
|
+
f"Match at {location} contains SQL keyword(s) ({keywords_str}); instead of keyword blacklisting, detect escaping characters like quotes or semicolons"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
transforms = rule_item.get("transform", [])
|
|
502
|
+
if isinstance(transforms, list) and "urldecode" in transforms:
|
|
503
|
+
if "%" in match_value:
|
|
504
|
+
location = f"rules{rule_path}" if rule_path else "rules"
|
|
505
|
+
warnings.append(
|
|
506
|
+
f"Match at {location} applies 'urldecode' but still contains percent-encoded characters; ensure the value is properly decoded or add another urldecode pass."
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
if "rules" in parsed and isinstance(parsed["rules"], list):
|
|
510
|
+
for i, rule in enumerate(parsed["rules"]):
|
|
511
|
+
check_rule_item(rule, f"[{i}]")
|
|
512
|
+
|
|
513
|
+
result_lines: List[str] = []
|
|
514
|
+
|
|
515
|
+
if not warnings and not hints:
|
|
516
|
+
result_lines.append("✅ LINT PASSED: No issues found")
|
|
517
|
+
LOGGER.info("Lint completed with no findings")
|
|
518
|
+
else:
|
|
519
|
+
if warnings:
|
|
520
|
+
result_lines.append("⚠️ WARNINGS:")
|
|
521
|
+
for warning in warnings:
|
|
522
|
+
result_lines.append(f" - {warning}")
|
|
523
|
+
LOGGER.warning("Lint completed with %s warning(s)", len(warnings))
|
|
524
|
+
|
|
525
|
+
if hints:
|
|
526
|
+
if warnings:
|
|
527
|
+
result_lines.append("")
|
|
528
|
+
result_lines.append("💡 HINTS:")
|
|
529
|
+
for hint in hints:
|
|
530
|
+
result_lines.append(f" - {hint}")
|
|
531
|
+
LOGGER.info("Lint completed with %s hint(s)", len(hints))
|
|
532
|
+
|
|
533
|
+
return [
|
|
534
|
+
types.TextContent(
|
|
535
|
+
type="text",
|
|
536
|
+
text="\n".join(result_lines),
|
|
537
|
+
)
|
|
538
|
+
]
|
|
539
|
+
|
|
540
|
+
except yaml.YAMLError as e:
|
|
541
|
+
LOGGER.error("Lint failed due to YAML error: %s", e)
|
|
542
|
+
return [
|
|
543
|
+
types.TextContent(
|
|
544
|
+
type="text",
|
|
545
|
+
text=f"❌ LINT ERROR: Cannot lint invalid YAML: {str(e)}",
|
|
546
|
+
)
|
|
547
|
+
]
|
|
548
|
+
except Exception as e:
|
|
549
|
+
LOGGER.error("Unexpected lint error: %s", e)
|
|
550
|
+
return [
|
|
551
|
+
types.TextContent(
|
|
552
|
+
type="text",
|
|
553
|
+
text=f"❌ LINT ERROR: Unexpected error: {str(e)}",
|
|
554
|
+
)
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _tool_get_waf_prompt(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
559
|
+
try:
|
|
560
|
+
LOGGER.info("Serving WAF prompt content")
|
|
561
|
+
prompt_content = WAF_PROMPT_FILE.read_text(encoding="utf-8")
|
|
562
|
+
return [
|
|
563
|
+
types.TextContent(
|
|
564
|
+
type="text",
|
|
565
|
+
text=prompt_content,
|
|
566
|
+
)
|
|
567
|
+
]
|
|
568
|
+
except FileNotFoundError:
|
|
569
|
+
LOGGER.error("WAF prompt file not found at %s", WAF_PROMPT_FILE)
|
|
570
|
+
return [
|
|
571
|
+
types.TextContent(
|
|
572
|
+
type="text",
|
|
573
|
+
text="Error: WAF prompt file not found.",
|
|
574
|
+
)
|
|
575
|
+
]
|
|
576
|
+
except Exception as exc:
|
|
577
|
+
LOGGER.error("Error loading WAF prompt: %s", exc)
|
|
578
|
+
return [
|
|
579
|
+
types.TextContent(
|
|
580
|
+
type="text",
|
|
581
|
+
text=f"Error reading WAF prompt file: {str(exc)}",
|
|
582
|
+
)
|
|
583
|
+
]
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _tool_get_waf_examples(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
587
|
+
try:
|
|
588
|
+
LOGGER.info("Serving WAF examples content")
|
|
589
|
+
examples_content = WAF_EXAMPLES_FILE.read_text(encoding="utf-8")
|
|
590
|
+
return [
|
|
591
|
+
types.TextContent(
|
|
592
|
+
type="text",
|
|
593
|
+
text=examples_content,
|
|
594
|
+
)
|
|
595
|
+
]
|
|
596
|
+
except FileNotFoundError:
|
|
597
|
+
LOGGER.error("WAF examples file not found at %s", WAF_EXAMPLES_FILE)
|
|
598
|
+
return [
|
|
599
|
+
types.TextContent(
|
|
600
|
+
type="text",
|
|
601
|
+
text="Error: WAF examples file not found.",
|
|
602
|
+
)
|
|
603
|
+
]
|
|
604
|
+
except Exception as exc:
|
|
605
|
+
LOGGER.error("Error loading WAF examples: %s", exc)
|
|
606
|
+
return [
|
|
607
|
+
types.TextContent(
|
|
608
|
+
type="text",
|
|
609
|
+
text=f"Error reading WAF examples file: {str(exc)}",
|
|
610
|
+
)
|
|
611
|
+
]
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _tool_generate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
615
|
+
try:
|
|
616
|
+
main_prompt = WAF_PROMPT_FILE.read_text(encoding="utf-8")
|
|
617
|
+
examples_prompt = WAF_EXAMPLES_FILE.read_text(encoding="utf-8")
|
|
618
|
+
|
|
619
|
+
combined_prompt = f"{main_prompt}\n\n{examples_prompt}"
|
|
620
|
+
|
|
621
|
+
nuclei_template = arguments.get("nuclei_template") if arguments else None
|
|
622
|
+
LOGGER.info(
|
|
623
|
+
"Generating WAF rule prompt (nuclei_template_present=%s)",
|
|
624
|
+
bool(nuclei_template),
|
|
625
|
+
)
|
|
626
|
+
if nuclei_template:
|
|
627
|
+
combined_prompt += (
|
|
628
|
+
"\n\n### Input Nuclei Template to Process:\n"
|
|
629
|
+
f"```yaml\n{nuclei_template}\n```"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return [
|
|
633
|
+
types.TextContent(
|
|
634
|
+
type="text",
|
|
635
|
+
text=combined_prompt,
|
|
636
|
+
)
|
|
637
|
+
]
|
|
638
|
+
except FileNotFoundError as exc:
|
|
639
|
+
LOGGER.error("Prompt generation failed due to missing file: %s", exc)
|
|
640
|
+
return [
|
|
641
|
+
types.TextContent(
|
|
642
|
+
type="text",
|
|
643
|
+
text=f"Error: Prompt file not found: {str(exc)}",
|
|
644
|
+
)
|
|
645
|
+
]
|
|
646
|
+
except Exception as exc:
|
|
647
|
+
LOGGER.error("Unexpected error generating WAF prompt: %s", exc)
|
|
648
|
+
return [
|
|
649
|
+
types.TextContent(
|
|
650
|
+
type="text",
|
|
651
|
+
text=f"Error generating WAF rule prompt: {str(exc)}",
|
|
652
|
+
)
|
|
653
|
+
]
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _tool_validate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
657
|
+
if not arguments or "rule_yaml" not in arguments:
|
|
658
|
+
LOGGER.warning("Validation request missing 'rule_yaml' argument")
|
|
659
|
+
return [
|
|
660
|
+
types.TextContent(
|
|
661
|
+
type="text",
|
|
662
|
+
text="Error: rule_yaml parameter is required",
|
|
663
|
+
)
|
|
664
|
+
]
|
|
665
|
+
|
|
666
|
+
rule_yaml = arguments["rule_yaml"]
|
|
667
|
+
LOGGER.info("Received validation request for WAF rule")
|
|
668
|
+
return _validate_waf_rule(rule_yaml)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _tool_lint_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
672
|
+
if not arguments or "rule_yaml" not in arguments:
|
|
673
|
+
LOGGER.warning("Lint request missing 'rule_yaml' argument")
|
|
674
|
+
return [
|
|
675
|
+
types.TextContent(
|
|
676
|
+
type="text",
|
|
677
|
+
text="Error: rule_yaml parameter is required",
|
|
678
|
+
)
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
rule_yaml = arguments["rule_yaml"]
|
|
682
|
+
LOGGER.info("Received lint request for WAF rule")
|
|
683
|
+
return _lint_waf_rule(rule_yaml)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _tool_deploy_waf_rule(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
687
|
+
try:
|
|
688
|
+
LOGGER.info("Serving WAF deployment guide content")
|
|
689
|
+
deploy_content = WAF_DEPLOY_FILE.read_text(encoding="utf-8")
|
|
690
|
+
return [
|
|
691
|
+
types.TextContent(
|
|
692
|
+
type="text",
|
|
693
|
+
text=deploy_content,
|
|
694
|
+
)
|
|
695
|
+
]
|
|
696
|
+
except FileNotFoundError:
|
|
697
|
+
LOGGER.error("WAF deployment guide missing at %s", WAF_DEPLOY_FILE)
|
|
698
|
+
return [
|
|
699
|
+
types.TextContent(
|
|
700
|
+
type="text",
|
|
701
|
+
text="Error: WAF deployment guide file not found.",
|
|
702
|
+
)
|
|
703
|
+
]
|
|
704
|
+
except Exception as exc:
|
|
705
|
+
LOGGER.error("Error loading WAF deployment guide: %s", exc)
|
|
706
|
+
return [
|
|
707
|
+
types.TextContent(
|
|
708
|
+
type="text",
|
|
709
|
+
text=f"Error reading WAF deployment guide: {str(exc)}",
|
|
710
|
+
)
|
|
711
|
+
]
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _tool_manage_waf_stack(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
715
|
+
try:
|
|
716
|
+
if not arguments:
|
|
717
|
+
LOGGER.warning("manage_waf_stack called without arguments")
|
|
718
|
+
raise ValueError("Missing arguments payload")
|
|
719
|
+
|
|
720
|
+
action = arguments.get("action")
|
|
721
|
+
if action not in {"start", "stop"}:
|
|
722
|
+
LOGGER.warning("manage_waf_stack received invalid action: %s", action)
|
|
723
|
+
raise ValueError("Action must be 'start' or 'stop'")
|
|
724
|
+
|
|
725
|
+
if action == "start":
|
|
726
|
+
rule_yaml = arguments.get("rule_yaml")
|
|
727
|
+
if not isinstance(rule_yaml, str) or not rule_yaml.strip():
|
|
728
|
+
LOGGER.warning("manage_waf_stack start called without rule YAML")
|
|
729
|
+
raise ValueError("'rule_yaml' must be provided when starting the stack")
|
|
730
|
+
|
|
731
|
+
LOGGER.info("manage_waf_stack starting WAF stack")
|
|
732
|
+
target_url, error_message = _start_waf_test_stack(rule_yaml)
|
|
733
|
+
if error_message:
|
|
734
|
+
LOGGER.error("Failed to start WAF stack: %s", error_message)
|
|
735
|
+
return [
|
|
736
|
+
types.TextContent(
|
|
737
|
+
type="text",
|
|
738
|
+
text=f"❌ WAF stack start error: {error_message}",
|
|
739
|
+
)
|
|
740
|
+
]
|
|
741
|
+
|
|
742
|
+
if not target_url:
|
|
743
|
+
LOGGER.error("WAF stack start returned no target URL and no explicit error")
|
|
744
|
+
return [
|
|
745
|
+
types.TextContent(
|
|
746
|
+
type="text",
|
|
747
|
+
text=(
|
|
748
|
+
"❌ WAF stack start error: stack did not return a service URL but also did not report a specific error."
|
|
749
|
+
),
|
|
750
|
+
)
|
|
751
|
+
]
|
|
752
|
+
|
|
753
|
+
return [
|
|
754
|
+
types.TextContent(
|
|
755
|
+
type="text",
|
|
756
|
+
text=(
|
|
757
|
+
"✅ WAF test stack is up. The nginx entry-point is available at "
|
|
758
|
+
f"{target_url}. Issue malicious payloads that should be blocked as well as "
|
|
759
|
+
"benign requests that must remain allowed, then use 'manage_waf_stack' with "
|
|
760
|
+
"action=stop when finished."
|
|
761
|
+
),
|
|
762
|
+
)
|
|
763
|
+
]
|
|
764
|
+
|
|
765
|
+
LOGGER.info("manage_waf_stack stopping WAF stack")
|
|
766
|
+
_stop_waf_test_stack()
|
|
767
|
+
return [
|
|
768
|
+
types.TextContent(
|
|
769
|
+
type="text",
|
|
770
|
+
text="🛑 WAF test stack stopped and containers removed",
|
|
771
|
+
)
|
|
772
|
+
]
|
|
773
|
+
|
|
774
|
+
except Exception as exc:
|
|
775
|
+
LOGGER.error("manage_waf_stack error: %s", exc)
|
|
776
|
+
return [
|
|
777
|
+
types.TextContent(
|
|
778
|
+
type="text",
|
|
779
|
+
text=f"❌ Stack management error: {str(exc)}",
|
|
780
|
+
)
|
|
781
|
+
]
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _search_repo_for_cve(repo_path: Path, cve: str) -> List[Path]:
|
|
785
|
+
"""Return files whose name contains the CVE identifier (case-insensitive)."""
|
|
786
|
+
lower_token = cve.lower()
|
|
787
|
+
matches: List[Path] = []
|
|
788
|
+
|
|
789
|
+
for candidate in repo_path.rglob("*"):
|
|
790
|
+
if not candidate.is_file():
|
|
791
|
+
continue
|
|
792
|
+
if lower_token in candidate.name.lower():
|
|
793
|
+
matches.append(candidate)
|
|
794
|
+
|
|
795
|
+
return matches
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
799
|
+
try:
|
|
800
|
+
if not arguments:
|
|
801
|
+
LOGGER.warning("fetch_nuclei_exploit called without arguments")
|
|
802
|
+
raise ValueError("Missing arguments payload")
|
|
803
|
+
|
|
804
|
+
raw_cve = arguments.get("cve")
|
|
805
|
+
if not isinstance(raw_cve, str) or not raw_cve.strip():
|
|
806
|
+
LOGGER.warning("fetch_nuclei_exploit received invalid CVE argument: %s", raw_cve)
|
|
807
|
+
raise ValueError("cve must be a non-empty string")
|
|
808
|
+
|
|
809
|
+
cve = raw_cve.strip().upper()
|
|
810
|
+
if not cve.startswith("CVE-"):
|
|
811
|
+
cve = f"CVE-{cve}"
|
|
812
|
+
|
|
813
|
+
target_path = DEFAULT_EXPLOIT_TARGET_DIR
|
|
814
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
815
|
+
|
|
816
|
+
LOGGER.info("Fetching nuclei exploit templates for %s", cve)
|
|
817
|
+
findings: List[str] = []
|
|
818
|
+
rendered_templates: List[str] = []
|
|
819
|
+
total_files = 0
|
|
820
|
+
|
|
821
|
+
for repo_url in DEFAULT_EXPLOIT_REPOSITORIES:
|
|
822
|
+
cleaned_url = repo_url.rstrip("/")
|
|
823
|
+
repo_name = cleaned_url.split("/")[-1] or "repository"
|
|
824
|
+
if repo_name.endswith(".git"):
|
|
825
|
+
repo_name = repo_name[:-4]
|
|
826
|
+
repo_path = target_path / repo_name
|
|
827
|
+
|
|
828
|
+
if repo_path.exists():
|
|
829
|
+
if not (repo_path / ".git").exists():
|
|
830
|
+
raise RuntimeError(
|
|
831
|
+
f"Destination {repo_path} exists but is not a git repository"
|
|
832
|
+
)
|
|
833
|
+
git_cmd = ["git", "-C", str(repo_path), "pull", "--ff-only"]
|
|
834
|
+
else:
|
|
835
|
+
git_cmd = ["git", "clone", "--depth", "1", cleaned_url, str(repo_path)]
|
|
836
|
+
|
|
837
|
+
git_result = subprocess.run(
|
|
838
|
+
git_cmd,
|
|
839
|
+
capture_output=True,
|
|
840
|
+
text=True,
|
|
841
|
+
)
|
|
842
|
+
if git_result.returncode != 0:
|
|
843
|
+
detail = (git_result.stderr or git_result.stdout or "git command failed").strip()
|
|
844
|
+
LOGGER.error("Git operation failed for %s: %s", cleaned_url, detail)
|
|
845
|
+
raise RuntimeError(f"git operation failed for {cleaned_url}: {detail}")
|
|
846
|
+
|
|
847
|
+
matched_files = _search_repo_for_cve(repo_path, cve)
|
|
848
|
+
if not matched_files:
|
|
849
|
+
continue
|
|
850
|
+
|
|
851
|
+
findings.append(f"Repository: {cleaned_url}")
|
|
852
|
+
for file_path in matched_files:
|
|
853
|
+
try:
|
|
854
|
+
relative_path = file_path.relative_to(repo_path)
|
|
855
|
+
except ValueError:
|
|
856
|
+
relative_path = file_path
|
|
857
|
+
findings.append(f" {relative_path}")
|
|
858
|
+
try:
|
|
859
|
+
try:
|
|
860
|
+
file_contents = file_path.read_text(encoding="utf-8")
|
|
861
|
+
except UnicodeDecodeError:
|
|
862
|
+
file_contents = file_path.read_text(encoding="utf-8", errors="replace")
|
|
863
|
+
except OSError as read_err:
|
|
864
|
+
findings.append(f" (failed to read {relative_path}: {read_err})")
|
|
865
|
+
continue
|
|
866
|
+
rendered_templates.append(
|
|
867
|
+
f"### {cleaned_url} :: {relative_path}\n```yaml\n{file_contents}\n```"
|
|
868
|
+
)
|
|
869
|
+
total_files += 1
|
|
870
|
+
|
|
871
|
+
if total_files == 0:
|
|
872
|
+
LOGGER.warning("No nuclei exploit templates found for %s", cve)
|
|
873
|
+
detail_section = "\n\nScan details:\n" + "\n".join(findings) if findings else ""
|
|
874
|
+
return [
|
|
875
|
+
types.TextContent(
|
|
876
|
+
type="text",
|
|
877
|
+
text=(
|
|
878
|
+
f"No files containing {cve} were found in the provided repositories."
|
|
879
|
+
f"{detail_section}"
|
|
880
|
+
),
|
|
881
|
+
)
|
|
882
|
+
]
|
|
883
|
+
|
|
884
|
+
summary_lines = [
|
|
885
|
+
f"Fetched {total_files} template(s) containing {cve} from configured repositories.",
|
|
886
|
+
"\n".join(findings),
|
|
887
|
+
"",
|
|
888
|
+
"Present each template below to the user inside a ```yaml``` code block:",
|
|
889
|
+
"",
|
|
890
|
+
"\n\n".join(rendered_templates),
|
|
891
|
+
]
|
|
892
|
+
|
|
893
|
+
return [
|
|
894
|
+
types.TextContent(
|
|
895
|
+
type="text",
|
|
896
|
+
text="\n".join(summary_lines),
|
|
897
|
+
)
|
|
898
|
+
]
|
|
899
|
+
|
|
900
|
+
except Exception as exc:
|
|
901
|
+
LOGGER.error("fetch_nuclei_exploit error: %s", exc)
|
|
902
|
+
return [
|
|
903
|
+
types.TextContent(
|
|
904
|
+
type="text",
|
|
905
|
+
text=f"❌ fetch nuclei exploit error: {str(exc)}",
|
|
906
|
+
)
|
|
907
|
+
]
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _tool_curl_waf_endpoint(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
|
|
911
|
+
try:
|
|
912
|
+
if not arguments:
|
|
913
|
+
LOGGER.warning("curl_waf_endpoint called without arguments")
|
|
914
|
+
raise ValueError("Missing arguments payload")
|
|
915
|
+
|
|
916
|
+
method = arguments.get("method")
|
|
917
|
+
path = arguments.get("path")
|
|
918
|
+
body = arguments.get("body")
|
|
919
|
+
headers = arguments.get("headers") or {}
|
|
920
|
+
timeout = arguments.get("timeout", 10)
|
|
921
|
+
|
|
922
|
+
if not isinstance(method, str) or not isinstance(path, str):
|
|
923
|
+
LOGGER.warning("curl_waf_endpoint received invalid method/path types")
|
|
924
|
+
raise ValueError("'method' and 'path' must be strings")
|
|
925
|
+
|
|
926
|
+
method = method.upper().strip()
|
|
927
|
+
path = path.strip() or "/"
|
|
928
|
+
|
|
929
|
+
if not path.startswith("/"):
|
|
930
|
+
if "://" in path:
|
|
931
|
+
parsed = urllib.parse.urlparse(path)
|
|
932
|
+
path = urllib.parse.urlunparse(
|
|
933
|
+
("", "", parsed.path or "/", parsed.params, parsed.query, parsed.fragment)
|
|
934
|
+
)
|
|
935
|
+
else:
|
|
936
|
+
path = "/" + path
|
|
937
|
+
|
|
938
|
+
if body is not None and not isinstance(body, str):
|
|
939
|
+
LOGGER.warning("curl_waf_endpoint received non-string body payload")
|
|
940
|
+
raise ValueError("'body' must be a string when provided")
|
|
941
|
+
|
|
942
|
+
LOGGER.info(
|
|
943
|
+
"curl_waf_endpoint executing %s request to %s (timeout=%s)", method, path, timeout
|
|
944
|
+
)
|
|
945
|
+
try:
|
|
946
|
+
response = requests.request(
|
|
947
|
+
method=method,
|
|
948
|
+
url=f"http://localhost:8081{path}",
|
|
949
|
+
headers=headers if isinstance(headers, dict) else {},
|
|
950
|
+
data=body,
|
|
951
|
+
timeout=timeout,
|
|
952
|
+
)
|
|
953
|
+
except requests.RequestException as req_err:
|
|
954
|
+
raise RuntimeError(f"HTTP request failed: {req_err}") from req_err
|
|
955
|
+
|
|
956
|
+
header_lines = "\n".join(f"{k}: {v}" for k, v in response.headers.items())
|
|
957
|
+
response_text = (
|
|
958
|
+
f">>> {method} http://localhost:8081{path}\n"
|
|
959
|
+
f"Status: {response.status_code}\n"
|
|
960
|
+
f"Headers:\n{header_lines}\n\n"
|
|
961
|
+
f"Body:\n{response.text}"
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
LOGGER.info(
|
|
965
|
+
"curl_waf_endpoint completed with status %s for %s %s",
|
|
966
|
+
response.status_code,
|
|
967
|
+
method,
|
|
968
|
+
path,
|
|
969
|
+
)
|
|
970
|
+
return [
|
|
971
|
+
types.TextContent(
|
|
972
|
+
type="text",
|
|
973
|
+
text=response_text,
|
|
974
|
+
)
|
|
975
|
+
]
|
|
976
|
+
|
|
977
|
+
except Exception as exc:
|
|
978
|
+
LOGGER.error("curl_waf_endpoint error: %s", exc)
|
|
979
|
+
return [
|
|
980
|
+
types.TextContent(
|
|
981
|
+
type="text",
|
|
982
|
+
text=f"❌ curl error: {str(exc)}",
|
|
983
|
+
)
|
|
984
|
+
]
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
WAF_TOOL_HANDLERS: Dict[str, ToolHandler] = {
|
|
988
|
+
"get_waf_prompt": _tool_get_waf_prompt,
|
|
989
|
+
"get_waf_examples": _tool_get_waf_examples,
|
|
990
|
+
"generate_waf_rule": _tool_generate_waf_rule,
|
|
991
|
+
"validate_waf_rule": _tool_validate_waf_rule,
|
|
992
|
+
"lint_waf_rule": _tool_lint_waf_rule,
|
|
993
|
+
"deploy_waf_rule": _tool_deploy_waf_rule,
|
|
994
|
+
"fetch_nuclei_exploit": _tool_fetch_nuclei_exploit,
|
|
995
|
+
"manage_waf_stack": _tool_manage_waf_stack,
|
|
996
|
+
"curl_waf_endpoint": _tool_curl_waf_endpoint,
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
WAF_TOOLS: List[types.Tool] = [
|
|
1000
|
+
types.Tool(
|
|
1001
|
+
name="get_waf_prompt",
|
|
1002
|
+
description="Get the main WAF rule generation prompt for CrowdSec",
|
|
1003
|
+
inputSchema={
|
|
1004
|
+
"type": "object",
|
|
1005
|
+
"properties": {},
|
|
1006
|
+
"additionalProperties": False,
|
|
1007
|
+
},
|
|
1008
|
+
),
|
|
1009
|
+
types.Tool(
|
|
1010
|
+
name="get_waf_examples",
|
|
1011
|
+
description="Get WAF rule generation examples for CrowdSec",
|
|
1012
|
+
inputSchema={
|
|
1013
|
+
"type": "object",
|
|
1014
|
+
"properties": {},
|
|
1015
|
+
"additionalProperties": False,
|
|
1016
|
+
},
|
|
1017
|
+
),
|
|
1018
|
+
types.Tool(
|
|
1019
|
+
name="generate_waf_rule",
|
|
1020
|
+
description="Get the complete WAF rule generation prompt (main prompt + examples) for CrowdSec",
|
|
1021
|
+
inputSchema={
|
|
1022
|
+
"type": "object",
|
|
1023
|
+
"properties": {
|
|
1024
|
+
"nuclei_template": {
|
|
1025
|
+
"type": "string",
|
|
1026
|
+
"description": "Optional Nuclei template to include in the prompt for immediate processing",
|
|
1027
|
+
}
|
|
1028
|
+
},
|
|
1029
|
+
"additionalProperties": False,
|
|
1030
|
+
},
|
|
1031
|
+
),
|
|
1032
|
+
types.Tool(
|
|
1033
|
+
name="validate_waf_rule",
|
|
1034
|
+
description="Validate that a CrowdSec WAF rule YAML is syntactically correct",
|
|
1035
|
+
inputSchema={
|
|
1036
|
+
"type": "object",
|
|
1037
|
+
"properties": {
|
|
1038
|
+
"rule_yaml": {
|
|
1039
|
+
"type": "string",
|
|
1040
|
+
"description": "The YAML content of the WAF rule to validate",
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
"required": ["rule_yaml"],
|
|
1044
|
+
"additionalProperties": False,
|
|
1045
|
+
},
|
|
1046
|
+
),
|
|
1047
|
+
types.Tool(
|
|
1048
|
+
name="lint_waf_rule",
|
|
1049
|
+
description="Lint a CrowdSec WAF rule and provide warnings/hints for improvement",
|
|
1050
|
+
inputSchema={
|
|
1051
|
+
"type": "object",
|
|
1052
|
+
"properties": {
|
|
1053
|
+
"rule_yaml": {
|
|
1054
|
+
"type": "string",
|
|
1055
|
+
"description": "The YAML content of the WAF rule to lint",
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
"required": ["rule_yaml"],
|
|
1059
|
+
"additionalProperties": False,
|
|
1060
|
+
},
|
|
1061
|
+
),
|
|
1062
|
+
types.Tool(
|
|
1063
|
+
name="deploy_waf_rule",
|
|
1064
|
+
description="Get deployment instructions for CrowdSec WAF rules",
|
|
1065
|
+
inputSchema={
|
|
1066
|
+
"type": "object",
|
|
1067
|
+
"properties": {},
|
|
1068
|
+
"additionalProperties": False,
|
|
1069
|
+
},
|
|
1070
|
+
),
|
|
1071
|
+
types.Tool(
|
|
1072
|
+
name="fetch_nuclei_exploit",
|
|
1073
|
+
description="Retrieve nuclei templates from the official repository for a CVE to help with generation of WAF rules",
|
|
1074
|
+
inputSchema={
|
|
1075
|
+
"type": "object",
|
|
1076
|
+
"properties": {
|
|
1077
|
+
"cve": {
|
|
1078
|
+
"type": "string",
|
|
1079
|
+
"description": "CVE identifier to search for (e.g. CVE-2024-12345)",
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1082
|
+
"required": ["cve"],
|
|
1083
|
+
"additionalProperties": False,
|
|
1084
|
+
},
|
|
1085
|
+
),
|
|
1086
|
+
types.Tool(
|
|
1087
|
+
name="manage_waf_stack",
|
|
1088
|
+
description="Start or stop the Docker-based CrowdSec AppSec test stack so the rule can be exercised with allowed and blocked requests",
|
|
1089
|
+
inputSchema={
|
|
1090
|
+
"type": "object",
|
|
1091
|
+
"properties": {
|
|
1092
|
+
"action": {
|
|
1093
|
+
"type": "string",
|
|
1094
|
+
"enum": ["start", "stop"],
|
|
1095
|
+
"description": "Whether to start or stop the stack",
|
|
1096
|
+
},
|
|
1097
|
+
"rule_yaml": {
|
|
1098
|
+
"type": "string",
|
|
1099
|
+
"description": "WAF rule YAML content to mount into the stack when starting",
|
|
1100
|
+
},
|
|
1101
|
+
},
|
|
1102
|
+
"required": ["action"],
|
|
1103
|
+
"additionalProperties": False,
|
|
1104
|
+
},
|
|
1105
|
+
),
|
|
1106
|
+
types.Tool(
|
|
1107
|
+
name="curl_waf_endpoint",
|
|
1108
|
+
description="Execute an HTTP request against the local WAF test endpoint (http://localhost:8081)",
|
|
1109
|
+
inputSchema={
|
|
1110
|
+
"type": "object",
|
|
1111
|
+
"properties": {
|
|
1112
|
+
"method": {
|
|
1113
|
+
"type": "string",
|
|
1114
|
+
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
|
1115
|
+
"description": "HTTP method to use",
|
|
1116
|
+
},
|
|
1117
|
+
"path": {
|
|
1118
|
+
"type": "string",
|
|
1119
|
+
"description": "Request path (e.g. /, /admin?x=y). Automatically prefixed with http://localhost:8081",
|
|
1120
|
+
},
|
|
1121
|
+
"body": {
|
|
1122
|
+
"type": "string",
|
|
1123
|
+
"description": "Optional request body",
|
|
1124
|
+
},
|
|
1125
|
+
"headers": {
|
|
1126
|
+
"type": "object",
|
|
1127
|
+
"description": "Optional headers to include",
|
|
1128
|
+
"additionalProperties": {"type": "string"},
|
|
1129
|
+
},
|
|
1130
|
+
"timeout": {
|
|
1131
|
+
"type": "number",
|
|
1132
|
+
"description": "Optional curl timeout in seconds",
|
|
1133
|
+
"minimum": 0.1,
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
"required": ["method", "path"],
|
|
1137
|
+
"additionalProperties": False,
|
|
1138
|
+
},
|
|
1139
|
+
),
|
|
1140
|
+
]
|
|
1141
|
+
|
|
1142
|
+
WAF_RESOURCES: List[types.Resource] = [
|
|
1143
|
+
types.Resource(
|
|
1144
|
+
uri="file://prompts/prompt-waf.txt",
|
|
1145
|
+
name="WAF Rule Generation Prompt",
|
|
1146
|
+
description="Main prompt for generating CrowdSec WAF rules from Nuclei templates",
|
|
1147
|
+
mimeType="text/plain",
|
|
1148
|
+
),
|
|
1149
|
+
types.Resource(
|
|
1150
|
+
uri="file://prompts/prompt-waf-examples.txt",
|
|
1151
|
+
name="WAF Rule Examples",
|
|
1152
|
+
description="Examples of WAF rule generation for CrowdSec",
|
|
1153
|
+
mimeType="text/plain",
|
|
1154
|
+
),
|
|
1155
|
+
types.Resource(
|
|
1156
|
+
uri="file://prompts/prompt-waf-deploy.txt",
|
|
1157
|
+
name="WAF Rule Deployment Guide",
|
|
1158
|
+
description="Step-by-step guide for deploying CrowdSec WAF rules",
|
|
1159
|
+
mimeType="text/plain",
|
|
1160
|
+
),
|
|
1161
|
+
]
|
|
1162
|
+
|
|
1163
|
+
WAF_RESOURCE_READERS: Dict[str, Callable[[], str]] = {
|
|
1164
|
+
"file://prompts/prompt-waf.txt": lambda: WAF_PROMPT_FILE.read_text(encoding="utf-8"),
|
|
1165
|
+
"file://prompts/prompt-waf-examples.txt": lambda: WAF_EXAMPLES_FILE.read_text(encoding="utf-8"),
|
|
1166
|
+
"file://prompts/prompt-waf-deploy.txt": lambda: WAF_DEPLOY_FILE.read_text(encoding="utf-8"),
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
REGISTRY.register_tools(WAF_TOOL_HANDLERS, WAF_TOOLS)
|
|
1170
|
+
REGISTRY.register_resources(WAF_RESOURCES, WAF_RESOURCE_READERS)
|