crowdsec-local-mcp 0.2.0__py3-none-any.whl → 0.8.0.post1.dev0__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.
Files changed (27) hide show
  1. crowdsec_local_mcp/__init__.py +6 -1
  2. crowdsec_local_mcp/__main__.py +1 -3
  3. crowdsec_local_mcp/_version.py +1 -0
  4. crowdsec_local_mcp/compose/scenario-test/.gitignore +1 -0
  5. crowdsec_local_mcp/compose/scenario-test/docker-compose.yml +19 -0
  6. crowdsec_local_mcp/compose/scenario-test/scenarios/.gitkeep +0 -0
  7. crowdsec_local_mcp/compose/waf-test/docker-compose.yml +5 -6
  8. crowdsec_local_mcp/compose/waf-test/nginx/Dockerfile +8 -2
  9. crowdsec_local_mcp/mcp_core.py +112 -18
  10. crowdsec_local_mcp/mcp_scenarios.py +579 -23
  11. crowdsec_local_mcp/mcp_waf.py +774 -402
  12. crowdsec_local_mcp/prompts/prompt-expr-helpers.txt +514 -0
  13. crowdsec_local_mcp/prompts/prompt-scenario-deploy.txt +70 -21
  14. crowdsec_local_mcp/prompts/prompt-scenario.txt +26 -2
  15. crowdsec_local_mcp/prompts/prompt-waf-pr.txt +10 -0
  16. crowdsec_local_mcp/prompts/prompt-waf-tests.txt +113 -0
  17. crowdsec_local_mcp/prompts/prompt-waf-top-level.txt +33 -0
  18. crowdsec_local_mcp/prompts/prompt-waf.txt +0 -26
  19. crowdsec_local_mcp/setup_cli.py +98 -29
  20. crowdsec_local_mcp-0.8.0.post1.dev0.dist-info/METADATA +114 -0
  21. crowdsec_local_mcp-0.8.0.post1.dev0.dist-info/RECORD +39 -0
  22. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.8.0.post1.dev0.dist-info}/WHEEL +1 -1
  23. crowdsec_local_mcp-0.2.0.dist-info/METADATA +0 -74
  24. crowdsec_local_mcp-0.2.0.dist-info/RECORD +0 -31
  25. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.8.0.post1.dev0.dist-info}/entry_points.txt +0 -0
  26. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.8.0.post1.dev0.dist-info}/licenses/LICENSE +0 -0
  27. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.8.0.post1.dev0.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,34 @@
1
+ import json
1
2
  import subprocess
3
+ import tempfile
2
4
  import time
3
5
  import urllib.parse
4
6
  from pathlib import Path
5
- from typing import Any, Callable, Dict, List, Optional, Tuple
7
+ from typing import Any
8
+ from collections.abc import Callable
6
9
 
7
10
  import jsonschema
8
11
  import requests
9
12
  import yaml
10
13
 
11
- import mcp.types as types
14
+ from mcp import types
12
15
 
13
- from .mcp_core import LOGGER, PROMPTS_DIR, REGISTRY, SCRIPT_DIR, ToolHandler
16
+ from .mcp_core import (
17
+ LOGGER,
18
+ PROMPTS_DIR,
19
+ REGISTRY,
20
+ SCRIPT_DIR,
21
+ ToolHandler,
22
+ ensure_docker_cli,
23
+ ensure_docker_compose_cli,
24
+ )
14
25
 
26
+ WAF_TOP_LEVEL_PROMPT_FILE = PROMPTS_DIR / "prompt-waf-top-level.txt"
15
27
  WAF_PROMPT_FILE = PROMPTS_DIR / "prompt-waf.txt"
16
28
  WAF_EXAMPLES_FILE = PROMPTS_DIR / "prompt-waf-examples.txt"
17
29
  WAF_DEPLOY_FILE = PROMPTS_DIR / "prompt-waf-deploy.txt"
30
+ WAF_TESTS_PROMPT_FILE = PROMPTS_DIR / "prompt-waf-tests.txt"
31
+ WAF_PR_PROMPT_FILE = PROMPTS_DIR / "prompt-waf-pr.txt"
18
32
 
19
33
  CROWDSEC_SCHEMAS_DIR = SCRIPT_DIR / "yaml-schemas"
20
34
  WAF_SCHEMA_FILE = CROWDSEC_SCHEMAS_DIR / "appsec_rules_schema.yaml"
@@ -22,20 +36,13 @@ WAF_SCHEMA_FILE = CROWDSEC_SCHEMAS_DIR / "appsec_rules_schema.yaml"
22
36
  WAF_TEST_COMPOSE_DIR = SCRIPT_DIR / "compose" / "waf-test"
23
37
  WAF_TEST_COMPOSE_FILE = WAF_TEST_COMPOSE_DIR / "docker-compose.yml"
24
38
  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
- )
39
+ WAF_TEST_APPSEC_TEMPLATE = WAF_TEST_COMPOSE_DIR / "crowdsec" / "appsec-configs" / "mcp-appsec.yaml.template"
40
+ WAF_TEST_APPSEC_CONFIG = WAF_TEST_COMPOSE_DIR / "crowdsec" / "appsec-configs" / "mcp-appsec.yaml"
37
41
  WAF_RULE_NAME_PLACEHOLDER = "__PLACEHOLDER_FOR_USER_RULE__"
38
42
  WAF_TEST_PROJECT_NAME = "crowdsec-mcp-waf"
43
+ WAF_TEST_NETWORK_NAME = f"{WAF_TEST_PROJECT_NAME}_waf-net"
44
+ WAF_DEFAULT_TARGET_URL = "http://nginx-appsec"
45
+ WAF_DEFAULT_NUCLEI_IMAGE = "projectdiscovery/nuclei:latest"
39
46
 
40
47
  DEFAULT_EXPLOIT_REPOSITORIES = [
41
48
  "https://github.com/projectdiscovery/nuclei-templates.git",
@@ -45,45 +52,11 @@ DEFAULT_EXPLOIT_TARGET_DIR = SCRIPT_DIR / "cached-exploits"
45
52
  CASE_SENSITIVE_MATCH_TYPES = ["regex", "contains", "startsWith", "endsWith", "equals"]
46
53
  SQL_KEYWORD_INDICATORS = ["union", "select", "insert", "update", "delete", "drop"]
47
54
 
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
- )
55
+ _COMPOSE_STACK_PROCESS: subprocess.Popen | None = None
83
56
 
84
57
 
85
- def _collect_compose_logs(services: Optional[List[str]] = None, tail_lines: int = 200) -> str:
86
- cmd = _detect_compose_command() + [
58
+ def _collect_compose_logs(services: list[str] | None = None, tail_lines: int = 200) -> str:
59
+ cmd = ensure_docker_compose_cli() + [
87
60
  "-p",
88
61
  WAF_TEST_PROJECT_NAME,
89
62
  "-f",
@@ -101,11 +74,7 @@ def _collect_compose_logs(services: Optional[List[str]] = None, tail_lines: int
101
74
  check=False,
102
75
  )
103
76
 
104
- combined = "\n".join(
105
- part.strip()
106
- for part in ((result.stdout or ""), (result.stderr or ""))
107
- if part
108
- ).strip()
77
+ combined = "\n".join(part.strip() for part in ((result.stdout or ""), (result.stderr or "")) if part).strip()
109
78
 
110
79
  if not combined:
111
80
  return ""
@@ -117,11 +86,9 @@ def _collect_compose_logs(services: Optional[List[str]] = None, tail_lines: int
117
86
  return "\n".join(lines)
118
87
 
119
88
 
120
- def _run_compose_command(
121
- args: List[str], capture_output: bool = True, check: bool = True
122
- ) -> subprocess.CompletedProcess:
89
+ def _run_compose_command(args: list[str], capture_output: bool = True, check: bool = True) -> subprocess.CompletedProcess:
123
90
  """Run a docker compose command inside the WAF test harness directory."""
124
- base_cmd = _detect_compose_command()
91
+ base_cmd = ensure_docker_compose_cli()
125
92
  full_cmd = base_cmd + ["-p", WAF_TEST_PROJECT_NAME, "-f", str(WAF_TEST_COMPOSE_FILE)] + args
126
93
  LOGGER.info("Executing compose command: %s", " ".join(full_cmd))
127
94
 
@@ -133,9 +100,9 @@ def _run_compose_command(
133
100
  capture_output=capture_output,
134
101
  text=True,
135
102
  )
136
- except FileNotFoundError as error:
103
+ except (FileNotFoundError, PermissionError) as error:
137
104
  LOGGER.error("Compose command failed to start: %s", error)
138
- raise RuntimeError(f"Failed to run {' '.join(base_cmd)}: {error}") from error
105
+ raise RuntimeError("Docker Compose is required but could not be executed. Install Docker and ensure the current user can run `docker compose` commands.") from error
139
106
  except subprocess.CalledProcessError as error:
140
107
  stdout = (error.stdout or "").strip()
141
108
  stderr = (error.stderr or "").strip()
@@ -147,14 +114,10 @@ def _run_compose_command(
147
114
  error.returncode,
148
115
  combined.splitlines()[0] if combined else "no output",
149
116
  )
150
- raise RuntimeError(
151
- f"docker compose {' '.join(args)} failed (exit code {error.returncode}):\n{combined}"
152
- ) from error
117
+ raise RuntimeError(f"docker compose {' '.join(args)} failed (exit code {error.returncode}):\n{combined}") from error
153
118
 
154
119
 
155
- def _run_compose_exec(
156
- args: List[str], capture_output: bool = True, check: bool = True
157
- ) -> subprocess.CompletedProcess:
120
+ def _run_compose_exec(args: list[str], capture_output: bool = True, check: bool = True) -> subprocess.CompletedProcess:
158
121
  """Run docker compose exec against the CrowdSec container."""
159
122
  exec_args = ["exec", "-T"] + args
160
123
  return _run_compose_command(exec_args, capture_output=capture_output, check=check)
@@ -164,9 +127,7 @@ def _teardown_compose_stack(check: bool = True) -> None:
164
127
  """Stop the compose stack and ensure any supervising process is terminated."""
165
128
  global _COMPOSE_STACK_PROCESS
166
129
  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
- )
130
+ LOGGER.warning("Requested stack teardown but compose file %s is missing", WAF_TEST_COMPOSE_FILE)
170
131
  _COMPOSE_STACK_PROCESS = None
171
132
  return
172
133
 
@@ -178,9 +139,7 @@ def _teardown_compose_stack(check: bool = True) -> None:
178
139
  try:
179
140
  _COMPOSE_STACK_PROCESS.wait(timeout=15)
180
141
  except subprocess.TimeoutExpired:
181
- LOGGER.warning(
182
- "Compose stack process did not exit in time; terminating forcefully"
183
- )
142
+ LOGGER.warning("Compose stack process did not exit in time; terminating forcefully")
184
143
  _COMPOSE_STACK_PROCESS.kill()
185
144
  _COMPOSE_STACK_PROCESS.wait(timeout=5)
186
145
  _COMPOSE_STACK_PROCESS = None
@@ -198,14 +157,9 @@ def _wait_for_crowdsec_ready(timeout: int = 90) -> None:
198
157
  _COMPOSE_STACK_PROCESS = None
199
158
  logs = _collect_compose_logs(["crowdsec", "nginx", "backend"])
200
159
  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
- )
160
+ raise RuntimeError(f"WAF stack exited while waiting for CrowdSec to become ready (exit code {exit_code}).{log_section}")
205
161
  try:
206
- result = _run_compose_exec(
207
- ["crowdsec", "cscli", "lapi", "status"], capture_output=True, check=False
208
- )
162
+ result = _run_compose_exec(["crowdsec", "cscli", "lapi", "status"], capture_output=True, check=False)
209
163
  if isinstance(result, subprocess.CompletedProcess) and result.returncode == 0:
210
164
  LOGGER.info("CrowdSec API is ready")
211
165
  return
@@ -217,7 +171,111 @@ def _wait_for_crowdsec_ready(timeout: int = 90) -> None:
217
171
  raise RuntimeError("CrowdSec local API did not become ready in time")
218
172
 
219
173
 
220
- def _start_waf_test_stack(rule_yaml: str) -> Tuple[Optional[str], Optional[str]]:
174
+ def _run_nuclei_container(
175
+ workspace: Path,
176
+ template_path: Path,
177
+ *,
178
+ nuclei_image: str,
179
+ target_url: str,
180
+ nuclei_args: list[str] | None = None,
181
+ timeout: int = 180,
182
+ ) -> tuple[bool, str]:
183
+ """Run the provided nuclei template inside a disposable docker container."""
184
+ rel_template = template_path.relative_to(workspace)
185
+ container_template_path = f"/nuclei/{rel_template.as_posix()}"
186
+
187
+ ensure_docker_cli()
188
+
189
+ command = [
190
+ "docker",
191
+ "run",
192
+ "--rm",
193
+ "--network",
194
+ WAF_TEST_NETWORK_NAME,
195
+ "-v",
196
+ f"{workspace}:/nuclei",
197
+ nuclei_image,
198
+ "-t",
199
+ container_template_path,
200
+ "-u",
201
+ target_url,
202
+ "-jsonl",
203
+ "-silent",
204
+ ]
205
+ if nuclei_args:
206
+ command.extend(str(arg) for arg in nuclei_args)
207
+
208
+ LOGGER.info("Executing nuclei container: %s", " ".join(command))
209
+
210
+ try:
211
+ result = subprocess.run(
212
+ command,
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=timeout,
216
+ check=False,
217
+ )
218
+ except subprocess.TimeoutExpired:
219
+ LOGGER.error("Nuclei container timed out after %s seconds", timeout)
220
+ return (
221
+ False,
222
+ "Nuclei execution timed out. Consider simplifying the template or increasing the timeout.",
223
+ )
224
+
225
+ stdout = (result.stdout or "").strip()
226
+ stderr = (result.stderr or "").strip()
227
+ details: list[str] = []
228
+ if stdout:
229
+ details.append(f"stdout:\n{stdout}")
230
+ if stderr:
231
+ details.append(f"stderr:\n{stderr}")
232
+ detail_text = "\n\n".join(details)
233
+
234
+ if result.returncode != 0:
235
+ LOGGER.error("Nuclei container exited with code %s", result.returncode)
236
+ failure = f"Nuclei container exited with status {result.returncode}." + (f"\n\n{detail_text}" if detail_text else "")
237
+ return (False, failure)
238
+
239
+ matches: list[dict[str, Any]] = []
240
+ unmatched_lines: list[str] = []
241
+ for line in stdout.splitlines():
242
+ if not line.strip():
243
+ continue
244
+ try:
245
+ payload = json.loads(line)
246
+ if isinstance(payload, dict):
247
+ matches.append(payload)
248
+ else:
249
+ unmatched_lines.append(line)
250
+ except json.JSONDecodeError:
251
+ unmatched_lines.append(line)
252
+
253
+ if not matches:
254
+ LOGGER.warning("Nuclei execution completed but no matches were reported")
255
+ info_lines = []
256
+ if unmatched_lines:
257
+ info_lines.append("Nuclei produced output but no matches were recorded:\n" + "\n".join(unmatched_lines))
258
+ else:
259
+ info_lines.append("Nuclei completed successfully but reported zero matches. The WAF rule likely did not block the request (missing HTTP 403).")
260
+ if stderr:
261
+ info_lines.append(f"stderr:\n{stderr}")
262
+ return (False, "\n\n".join(info_lines))
263
+
264
+ summary_lines = [
265
+ f"Nuclei reported {len(matches)} match(es) using template {rel_template.name}.",
266
+ ]
267
+ for match in matches:
268
+ template_id = match.get("template-id") or match.get("templateID") or rel_template.stem
269
+ url = match.get("matched-at") or match.get("matchedAt") or target_url
270
+ summary_lines.append(f" - {template_id} matched at {url}")
271
+ if unmatched_lines:
272
+ summary_lines.append("Additional nuclei output:\n" + "\n".join(unmatched_lines))
273
+ if stderr:
274
+ summary_lines.append(f"stderr:\n{stderr}")
275
+ return (True, "\n".join(summary_lines))
276
+
277
+
278
+ def _start_waf_test_stack(rule_yaml: str) -> tuple[str | None, str | None]:
221
279
  global _COMPOSE_STACK_PROCESS
222
280
  LOGGER.info("Starting WAF test stack")
223
281
  if not WAF_TEST_COMPOSE_FILE.exists():
@@ -289,7 +347,7 @@ def _start_waf_test_stack(rule_yaml: str) -> Tuple[Optional[str], Optional[str]]
289
347
  _teardown_compose_stack(check=False)
290
348
  return (None, f"{error}{log_section}")
291
349
 
292
- compose_base = _detect_compose_command() + [
350
+ compose_base = ensure_docker_compose_cli() + [
293
351
  "-p",
294
352
  WAF_TEST_PROJECT_NAME,
295
353
  "-f",
@@ -306,11 +364,12 @@ def _start_waf_test_stack(rule_yaml: str) -> Tuple[Optional[str], Optional[str]]
306
364
  stdout=subprocess.DEVNULL,
307
365
  stderr=subprocess.STDOUT,
308
366
  )
309
- except FileNotFoundError:
367
+ LOGGER.info("Launched docker compose process with PID %s", process.pid)
368
+ except (FileNotFoundError, PermissionError):
310
369
  LOGGER.error("Failed to launch docker compose process")
311
370
  return (
312
371
  None,
313
- "Docker Compose is required but could not be executed. Ensure Docker is installed and available.",
372
+ "Docker Compose is required but could not be executed. Ensure Docker is installed and the current user can run Docker commands.",
314
373
  )
315
374
 
316
375
  _COMPOSE_STACK_PROCESS = process
@@ -336,226 +395,206 @@ def _stop_waf_test_stack() -> None:
336
395
  _teardown_compose_stack(check=True)
337
396
 
338
397
 
339
- def _validate_waf_rule(rule_yaml: str) -> List[types.TextContent]:
398
+ def _validate_waf_rule(rule_yaml: str) -> list[types.TextContent]:
340
399
  """Validate that a CrowdSec WAF rule YAML conforms to the schema."""
341
400
  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
- ]
401
+ if not WAF_SCHEMA_FILE.exists():
402
+ LOGGER.error("Schema file missing at %s", WAF_SCHEMA_FILE)
403
+ raise FileNotFoundError(f"Schema file {WAF_SCHEMA_FILE} not found")
351
404
 
405
+ try:
352
406
  schema = yaml.safe_load(WAF_SCHEMA_FILE.read_text(encoding="utf-8"))
407
+ except yaml.YAMLError as exc:
408
+ LOGGER.error("Failed to parse WAF schema YAML: %s", exc)
409
+ raise ValueError(f"Unable to parse WAF schema YAML: {exc!s}") from exc
410
+
411
+ try:
353
412
  parsed = yaml.safe_load(rule_yaml)
413
+ except yaml.YAMLError as exc:
414
+ LOGGER.error("YAML syntax error during validation: %s", exc)
415
+ raise ValueError(f"YAML syntax error: {exc!s}") from exc
354
416
 
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
- ]
417
+ if parsed is None:
418
+ LOGGER.warning("Validation request received empty YAML content")
419
+ raise ValueError("Empty or invalid YAML content")
363
420
 
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
- ]
421
+ if not isinstance(parsed, dict):
422
+ raise ValueError("YAML must be a dictionary/object")
371
423
 
424
+ try:
372
425
  jsonschema.validate(instance=parsed, schema=schema)
426
+ except jsonschema.ValidationError as exc:
427
+ error_path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "root"
428
+ LOGGER.warning("Schema validation error at %s: %s", error_path, exc.message)
429
+ raise ValueError(f"Schema validation error at {error_path}: {exc.message}") from exc
430
+ except jsonschema.SchemaError as exc:
431
+ LOGGER.error("Invalid schema encountered: %s", exc)
432
+ raise RuntimeError(f"Invalid schema: {exc!s}") from exc
433
+
434
+ LOGGER.info("WAF rule validation passed")
435
+ return [
436
+ types.TextContent(
437
+ type="text",
438
+ text="✅ VALIDATION PASSED: Rule conforms to CrowdSec AppSec schema",
439
+ )
440
+ ]
373
441
 
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
442
 
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)}",
443
+ def _analyze_rule_item(rule_item: Any, rule_path: str, warnings: list[str]) -> tuple[bool, bool]:
444
+ """Recursively inspect rule items, track operator usage, and record warnings."""
445
+ if not isinstance(rule_item, dict):
446
+ return (False, False)
447
+
448
+ location = f"rules{rule_path}" if rule_path else "rules"
449
+ has_and = "and" in rule_item
450
+ has_or = "or" in rule_item
451
+ contains_and = has_and
452
+ contains_or = has_or
453
+
454
+ if has_and and has_or:
455
+ warnings.append(f"{location} mixes 'and' and 'or' operators at the same level; split them into separate nested blocks")
456
+
457
+ if has_and:
458
+ for i, sub_rule in enumerate(rule_item["and"]):
459
+ child_and, child_or = _analyze_rule_item(
460
+ sub_rule,
461
+ f"{rule_path}.and[{i}]",
462
+ warnings,
405
463
  )
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)}",
464
+ contains_and = contains_and or child_and
465
+ contains_or = contains_or or child_or
466
+
467
+ if has_or:
468
+ for i, sub_rule in enumerate(rule_item["or"]):
469
+ child_and, child_or = _analyze_rule_item(
470
+ sub_rule,
471
+ f"{rule_path}.or[{i}]",
472
+ warnings,
413
473
  )
414
- ]
474
+ contains_and = contains_and or child_and
475
+ contains_or = contains_or or child_or
415
476
 
477
+ if "match" in rule_item and not (has_and or has_or):
478
+ match = rule_item["match"]
479
+ if isinstance(match, dict):
480
+ match_type = match.get("type", "")
481
+ match_value = match.get("value", "")
416
482
 
417
- def _lint_waf_rule(rule_yaml: str) -> List[types.TextContent]:
483
+ if match_type in CASE_SENSITIVE_MATCH_TYPES and isinstance(match_value, str) and any(c.isupper() for c in match_value):
484
+ transforms = rule_item.get("transform", [])
485
+ has_lowercase = "lowercase" in transforms if isinstance(transforms, list) else False
486
+
487
+ if not has_lowercase:
488
+ warnings.append(
489
+ f"Match at {location} uses '{match_type}' with uppercase letters but no 'lowercase' transform - consider adding lowercase transform for case-insensitive matching"
490
+ )
491
+
492
+ if isinstance(match_value, str):
493
+ lower_value = match_value.lower()
494
+ sql_keywords = [kw for kw in SQL_KEYWORD_INDICATORS if kw in lower_value]
495
+ if sql_keywords:
496
+ keywords_str = ", ".join(sorted(set(sql_keywords)))
497
+ warnings.append(f"Match at {location} contains SQL keyword(s) ({keywords_str}); instead of keyword blacklisting, detect escaping characters like quotes or semicolons")
498
+
499
+ transforms = rule_item.get("transform", [])
500
+ if isinstance(transforms, list) and "urldecode" in transforms:
501
+ if "%" in match_value:
502
+ warnings.append(
503
+ f"Match at {location} applies 'urldecode' but still contains percent-encoded characters; ensure the value is properly decoded or add another urldecode pass."
504
+ )
505
+
506
+ return (contains_and, contains_or)
507
+
508
+
509
+ def lint_waf_rule(rule_yaml: str) -> list[types.TextContent]:
418
510
  """Lint a CrowdSec WAF rule and provide warnings/hints for improvement."""
419
511
  LOGGER.info("Linting WAF rule YAML (size=%s bytes)", len(rule_yaml.encode("utf-8")))
420
512
  try:
421
513
  parsed = yaml.safe_load(rule_yaml)
514
+ except yaml.YAMLError as exc:
515
+ LOGGER.error("Lint failed due to YAML error: %s", exc)
516
+ raise ValueError(f"Cannot lint invalid YAML: {exc!s}") from exc
422
517
 
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] = []
518
+ if parsed is None:
519
+ LOGGER.warning("Lint request failed: YAML content was empty or invalid")
520
+ raise ValueError("Cannot lint empty or invalid YAML")
434
521
 
435
- if not isinstance(parsed, dict):
436
- warnings.append("Rule should be a YAML dictionary")
522
+ warnings: list[str] = []
523
+ hints: list[str] = []
437
524
 
438
- if "name" not in parsed:
439
- warnings.append("Missing 'name' field")
525
+ if not isinstance(parsed, dict):
526
+ warnings.append("Rule should be a YAML dictionary")
440
527
 
441
- if "rules" not in parsed:
442
- warnings.append("Missing 'rules' field")
528
+ if "name" not in parsed:
529
+ warnings.append("Missing 'name' field")
443
530
 
444
- if "labels" not in parsed:
445
- warnings.append("Missing 'labels' field")
531
+ if "rules" not in parsed:
532
+ warnings.append("Missing 'rules' field")
446
533
 
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
- )
534
+ if "labels" not in parsed:
535
+ warnings.append("Missing 'labels' field")
483
536
 
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")
537
+ if "name" in parsed:
538
+ name = parsed.get("name", "")
539
+ if isinstance(name, str):
540
+ if name.startswith("crowdsecurity/"):
541
+ warnings.append("Rule name starts with 'crowdsecurity/' which is reserved for official CrowdSec rules; consider using your own namespace")
518
542
  else:
543
+ warnings.append("Field 'name' should be a string")
544
+
545
+ if "rules" in parsed and isinstance(parsed["rules"], list):
546
+ for i, rule in enumerate(parsed["rules"]):
547
+ rule_has_and, rule_has_or = _analyze_rule_item(rule, f"[{i}]", warnings)
548
+ if rule_has_and and rule_has_or:
549
+ warnings.append(f"rules[{i}] uses both 'and' and 'or' operators somewhere in the block; CrowdSec cannot mix them in one rule, split the logic into separate rules")
550
+
551
+ result_lines: list[str] = []
552
+
553
+ if not warnings and not hints:
554
+ result_lines.append("✅ LINT PASSED: No issues found")
555
+ LOGGER.info("Lint completed with no findings")
556
+ else:
557
+ if warnings:
558
+ result_lines.append("⚠️ WARNINGS:")
559
+ for warning in warnings:
560
+ result_lines.append(f" - {warning}")
561
+ LOGGER.warning("Lint completed with %s warning(s)", len(warnings))
562
+
563
+ if hints:
519
564
  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))
565
+ result_lines.append("")
566
+ result_lines.append("💡 HINTS:")
567
+ for hint in hints:
568
+ result_lines.append(f" - {hint}")
569
+ LOGGER.info("Lint completed with %s hint(s)", len(hints))
570
+
571
+ return [
572
+ types.TextContent(
573
+ type="text",
574
+ text="\n".join(result_lines),
575
+ )
576
+ ]
532
577
 
533
- return [
534
- types.TextContent(
535
- type="text",
536
- text="\n".join(result_lines),
537
- )
538
- ]
539
578
 
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)
579
+ def _tool_get_waf_top_level_prompt(_: dict[str, Any] | None) -> list[types.TextContent]:
580
+ try:
581
+ LOGGER.info("Serving WAF top-level orchestration prompt content")
582
+ prompt_content = WAF_TOP_LEVEL_PROMPT_FILE.read_text(encoding="utf-8")
550
583
  return [
551
584
  types.TextContent(
552
585
  type="text",
553
- text=f"❌ LINT ERROR: Unexpected error: {str(e)}",
586
+ text=prompt_content,
554
587
  )
555
588
  ]
589
+ except FileNotFoundError as exc:
590
+ LOGGER.error("WAF top-level prompt file not found at %s", WAF_TOP_LEVEL_PROMPT_FILE)
591
+ raise FileNotFoundError(f"WAF top-level prompt file not found at {WAF_TOP_LEVEL_PROMPT_FILE}") from exc
592
+ except Exception as exc:
593
+ LOGGER.error("Error loading WAF top-level prompt: %s", exc)
594
+ raise RuntimeError(f"Error reading WAF top-level prompt file: {exc!s}") from exc
556
595
 
557
596
 
558
- def _tool_get_waf_prompt(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
597
+ def _tool_get_waf_prompt(_: dict[str, Any] | None) -> list[types.TextContent]:
559
598
  try:
560
599
  LOGGER.info("Serving WAF prompt content")
561
600
  prompt_content = WAF_PROMPT_FILE.read_text(encoding="utf-8")
@@ -565,25 +604,15 @@ def _tool_get_waf_prompt(_: Optional[Dict[str, Any]]) -> List[types.TextContent]
565
604
  text=prompt_content,
566
605
  )
567
606
  ]
568
- except FileNotFoundError:
607
+ except FileNotFoundError as exc:
569
608
  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
- ]
609
+ raise FileNotFoundError(f"WAF prompt file not found at {WAF_PROMPT_FILE}") from exc
576
610
  except Exception as exc:
577
611
  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
- ]
612
+ raise RuntimeError(f"Error reading WAF prompt file: {exc!s}") from exc
584
613
 
585
614
 
586
- def _tool_get_waf_examples(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
615
+ def _tool_get_waf_examples(_: dict[str, Any] | None) -> list[types.TextContent]:
587
616
  try:
588
617
  LOGGER.info("Serving WAF examples content")
589
618
  examples_content = WAF_EXAMPLES_FILE.read_text(encoding="utf-8")
@@ -593,25 +622,15 @@ def _tool_get_waf_examples(_: Optional[Dict[str, Any]]) -> List[types.TextConten
593
622
  text=examples_content,
594
623
  )
595
624
  ]
596
- except FileNotFoundError:
625
+ except FileNotFoundError as exc:
597
626
  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
- ]
627
+ raise FileNotFoundError(f"WAF examples file not found at {WAF_EXAMPLES_FILE}") from exc
604
628
  except Exception as exc:
605
629
  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
- ]
630
+ raise RuntimeError(f"Error reading WAF examples file: {exc!s}") from exc
612
631
 
613
632
 
614
- def _tool_generate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
633
+ def _tool_generate_waf_rule(arguments: dict[str, Any] | None) -> list[types.TextContent]:
615
634
  try:
616
635
  main_prompt = WAF_PROMPT_FILE.read_text(encoding="utf-8")
617
636
  examples_prompt = WAF_EXAMPLES_FILE.read_text(encoding="utf-8")
@@ -624,10 +643,7 @@ def _tool_generate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.T
624
643
  bool(nuclei_template),
625
644
  )
626
645
  if nuclei_template:
627
- combined_prompt += (
628
- "\n\n### Input Nuclei Template to Process:\n"
629
- f"```yaml\n{nuclei_template}\n```"
630
- )
646
+ combined_prompt += f"\n\n### Input Nuclei Template to Process:\n```yaml\n{nuclei_template}\n```"
631
647
 
632
648
  return [
633
649
  types.TextContent(
@@ -637,53 +653,86 @@ def _tool_generate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.T
637
653
  ]
638
654
  except FileNotFoundError as exc:
639
655
  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
- ]
656
+ raise FileNotFoundError(f"Prompt file not found: {exc!s}") from exc
646
657
  except Exception as exc:
647
658
  LOGGER.error("Unexpected error generating WAF prompt: %s", exc)
659
+ raise RuntimeError(f"Error generating WAF rule prompt: {exc!s}") from exc
660
+
661
+
662
+ def _tool_generate_waf_tests(arguments: dict[str, Any] | None) -> list[types.TextContent]:
663
+ try:
664
+ tests_prompt = WAF_TESTS_PROMPT_FILE.read_text(encoding="utf-8")
665
+ nuclei_template = arguments.get("nuclei_template") if arguments else None
666
+ rule_filename = arguments.get("rule_filename") if arguments else None
667
+
668
+ LOGGER.info(
669
+ "Generating WAF test prompt (nuclei_template_present=%s, rule_filename_present=%s)",
670
+ bool(nuclei_template),
671
+ bool(rule_filename),
672
+ )
673
+
674
+ combined_prompt = tests_prompt
675
+
676
+ if rule_filename:
677
+ combined_prompt += f"\n\n### Rule Under Test\nThe detection rule produced earlier is stored at: {rule_filename}\nUse this exact path in the config.yaml `appsec-rules` list."
678
+
679
+ if nuclei_template:
680
+ combined_prompt += f"\n\n### Input Nuclei Template to Adapt:\n```yaml\n{nuclei_template}\n```"
681
+
648
682
  return [
649
683
  types.TextContent(
650
684
  type="text",
651
- text=f"Error generating WAF rule prompt: {str(exc)}",
685
+ text=combined_prompt,
652
686
  )
653
687
  ]
688
+ except FileNotFoundError as exc:
689
+ LOGGER.error("WAF test prompt missing: %s", exc)
690
+ raise FileNotFoundError(f"WAF test prompt file not found: {exc!s}") from exc
691
+ except Exception as exc:
692
+ LOGGER.error("Unexpected error generating WAF test prompt: %s", exc)
693
+ raise RuntimeError(f"Error generating WAF test prompt: {exc!s}") from exc
654
694
 
655
695
 
656
- def _tool_validate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
696
+ def _tool_get_waf_pr_prompt(_: dict[str, Any] | None) -> list[types.TextContent]:
697
+ try:
698
+ LOGGER.info("Serving WAF PR prompt content")
699
+ prompt_content = WAF_PR_PROMPT_FILE.read_text(encoding="utf-8")
700
+ return [types.TextContent(type="text", text=prompt_content)]
701
+ except FileNotFoundError as exc:
702
+ LOGGER.error("WAF PR prompt file not found at %s", WAF_PR_PROMPT_FILE)
703
+ raise FileNotFoundError(f"WAF PR prompt file not found at {WAF_PR_PROMPT_FILE}") from exc
704
+ except Exception as exc:
705
+ LOGGER.error("Error reading WAF PR prompt: %s", exc)
706
+ raise RuntimeError(f"Error reading WAF PR prompt: {exc!s}") from exc
707
+
708
+
709
+ def _tool_validate_waf_rule(arguments: dict[str, Any] | None) -> list[types.TextContent]:
657
710
  if not arguments or "rule_yaml" not in arguments:
658
711
  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
- ]
712
+ raise ValueError("rule_yaml parameter is required")
665
713
 
666
714
  rule_yaml = arguments["rule_yaml"]
715
+ if not isinstance(rule_yaml, str):
716
+ raise TypeError("rule_yaml must be provided as a string")
717
+
667
718
  LOGGER.info("Received validation request for WAF rule")
668
719
  return _validate_waf_rule(rule_yaml)
669
720
 
670
721
 
671
- def _tool_lint_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
722
+ def _tool_lint_waf_rule(arguments: dict[str, Any] | None) -> list[types.TextContent]:
672
723
  if not arguments or "rule_yaml" not in arguments:
673
724
  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
- ]
725
+ raise ValueError("rule_yaml parameter is required")
680
726
 
681
727
  rule_yaml = arguments["rule_yaml"]
728
+ if not isinstance(rule_yaml, str):
729
+ raise TypeError("rule_yaml must be provided as a string")
730
+
682
731
  LOGGER.info("Received lint request for WAF rule")
683
- return _lint_waf_rule(rule_yaml)
732
+ return lint_waf_rule(rule_yaml)
684
733
 
685
734
 
686
- def _tool_deploy_waf_rule(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
735
+ def _tool_deploy_waf_rule(_: dict[str, Any] | None) -> list[types.TextContent]:
687
736
  try:
688
737
  LOGGER.info("Serving WAF deployment guide content")
689
738
  deploy_content = WAF_DEPLOY_FILE.read_text(encoding="utf-8")
@@ -693,25 +742,165 @@ def _tool_deploy_waf_rule(_: Optional[Dict[str, Any]]) -> List[types.TextContent
693
742
  text=deploy_content,
694
743
  )
695
744
  ]
696
- except FileNotFoundError:
745
+ except FileNotFoundError as exc:
697
746
  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
- ]
747
+ raise FileNotFoundError(f"WAF deployment guide file not found at {WAF_DEPLOY_FILE}") from exc
704
748
  except Exception as exc:
705
749
  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
- ]
750
+ raise RuntimeError(f"Error reading WAF deployment guide: {exc!s}") from exc
712
751
 
713
752
 
714
- def _tool_manage_waf_stack(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
753
+ def _is_relative_to(base: Path, target: Path) -> bool:
754
+ try:
755
+ target.relative_to(base)
756
+ except ValueError:
757
+ return False
758
+ return True
759
+
760
+ def _require_non_empty_str(value: Any, field: str, message: str | None = None) -> str:
761
+ if not isinstance(value, str) or not value.strip():
762
+ raise ValueError(message or f"'{field}' must be a non-empty string")
763
+ return value.strip()
764
+
765
+
766
+ def _parse_collection_name(collection_name: str) -> tuple[str, str]:
767
+ normalized = collection_name.strip().lstrip("/")
768
+ if normalized.count("/") != 1:
769
+ raise ValueError("'collection_name' must be in the format 'author/name'")
770
+ author, name = normalized.split("/", 1)
771
+ if not author or not name:
772
+ raise ValueError("'collection_name' must be in the format 'author/name'")
773
+ return author, name
774
+
775
+
776
+ def _load_yaml_mapping(payload: str, error_context: str) -> dict[str, Any]:
777
+ try:
778
+ parsed = yaml.safe_load(payload) or {}
779
+ except yaml.YAMLError as exc:
780
+ raise RuntimeError(f"Failed to parse {error_context}: {exc}") from exc
781
+ if not isinstance(parsed, dict):
782
+ raise RuntimeError(f"{error_context} must be a mapping")
783
+ return parsed
784
+
785
+
786
+ def _update_collection_with_rule(
787
+ summary_lines: list[str],
788
+ hub_root: Path,
789
+ collection_name: str,
790
+ rule_yaml: str,
791
+ ) -> None:
792
+ author, name = _parse_collection_name(collection_name)
793
+ collection_file = (hub_root / "collections" / author / f"{name}.yaml").resolve()
794
+ if not _is_relative_to(hub_root, collection_file):
795
+ raise ValueError("Resolved collection path escapes the hub directory")
796
+ if not collection_file.exists():
797
+ summary_lines.append(f"- Warning: collection file not found at {collection_file}. Add the rule manually.")
798
+ return
799
+
800
+ collection_payload = _load_yaml_mapping(
801
+ collection_file.read_text(encoding="utf-8"),
802
+ f"collection YAML at {collection_file}",
803
+ )
804
+ appsec_rules = collection_payload.get("appsec-rules")
805
+ if appsec_rules is None:
806
+ appsec_rules = []
807
+ collection_payload["appsec-rules"] = appsec_rules
808
+ if not isinstance(appsec_rules, list):
809
+ raise RuntimeError("'appsec-rules' in the collection file must be a list")
810
+
811
+ rule_payload = _load_yaml_mapping(rule_yaml, "rule YAML to read the rule name")
812
+ rule_name = rule_payload.get("name")
813
+ if not isinstance(rule_name, str) or not rule_name.strip():
814
+ raise RuntimeError("Rule YAML must include a non-empty 'name' field to update the collection")
815
+ rule_name = rule_name.strip()
816
+
817
+ if rule_name in appsec_rules:
818
+ summary_lines.append("- Collection already includes the rule; no update needed")
819
+ return
820
+
821
+ appsec_rules.append(rule_name)
822
+ collection_file.write_text(
823
+ yaml.safe_dump(collection_payload, sort_keys=False),
824
+ encoding="utf-8",
825
+ )
826
+ summary_lines.append(f"- Updated collection: added {rule_name} to {collection_file}")
827
+
828
+ def _tool_prepare_waf_pr(arguments: dict[str, Any] | None) -> list[types.TextContent]:
829
+ if not arguments:
830
+ LOGGER.warning("prepare_waf_pr called without arguments")
831
+ raise ValueError("Missing arguments payload")
832
+
833
+ hub_dir = _require_non_empty_str(
834
+ arguments.get("hub_dir"),
835
+ "hub_dir",
836
+ "'hub_dir' must be a non-empty string pointing to a local CrowdSec hub clone",
837
+ )
838
+ rule_yaml = _require_non_empty_str(
839
+ arguments.get("rule_yaml"),
840
+ "rule_yaml",
841
+ "'rule_yaml' must be a non-empty string containing the WAF rule",
842
+ )
843
+ test_config_yaml = _require_non_empty_str(
844
+ arguments.get("test_config_yaml"),
845
+ "test_config_yaml",
846
+ "'test_config_yaml' must be a non-empty string containing the test config.yaml",
847
+ )
848
+ test_nuclei_yaml = _require_non_empty_str(
849
+ arguments.get("test_nuclei_yaml"),
850
+ "test_nuclei_yaml",
851
+ "'test_nuclei_yaml' must be a non-empty string containing the adapted nuclei template",
852
+ )
853
+ rule_filename = _require_non_empty_str(arguments.get("rule_filename"), "rule_filename")
854
+ nuclei_filename = _require_non_empty_str(arguments.get("nuclei_filename"), "nuclei_filename")
855
+ collection_name = arguments.get("collection_name")
856
+ if collection_name is not None and (not isinstance(collection_name, str) or not collection_name.strip()):
857
+ raise ValueError("'collection_name' must be a non-empty string when provided")
858
+
859
+ hub_path = Path(hub_dir).expanduser()
860
+ hub_root = hub_path.resolve()
861
+ if not hub_path.exists() or not hub_path.is_dir():
862
+ raise ValueError(f"hub_dir does not exist or is not a directory: {hub_path}")
863
+
864
+ rule_rel_path = Path(rule_filename.strip())
865
+
866
+ rule_path = (hub_path / rule_rel_path).resolve()
867
+ if not _is_relative_to(hub_root, rule_path):
868
+ raise ValueError("Resolved rule path escapes the hub directory")
869
+
870
+ rule_path.parent.mkdir(parents=True, exist_ok=True)
871
+ rule_path.write_text(rule_yaml, encoding="utf-8")
872
+
873
+ test_dir_name = rule_path.stem
874
+ tests_root = (hub_path / ".appsec-tests" / test_dir_name).resolve()
875
+ if not _is_relative_to(hub_root, tests_root):
876
+ raise ValueError("Resolved test path escapes the hub directory")
877
+ tests_root.mkdir(parents=True, exist_ok=True)
878
+
879
+ nuclei_file = nuclei_filename.strip()
880
+ if not nuclei_file.endswith(".yaml"):
881
+ nuclei_file = f"{nuclei_file}.yaml"
882
+
883
+ config_path = tests_root / "config.yaml"
884
+ nuclei_path = tests_root / nuclei_file
885
+ config_path.write_text(test_config_yaml, encoding="utf-8")
886
+ nuclei_path.write_text(test_nuclei_yaml, encoding="utf-8")
887
+
888
+ summary_lines = [
889
+ "Prepared PR assets in the hub clone:",
890
+ f"- Rule: {rule_path}",
891
+ f"- Test config: {config_path}",
892
+ f"- Test nuclei: {nuclei_path}",
893
+ ]
894
+
895
+ if collection_name:
896
+ _update_collection_with_rule(summary_lines, hub_root, collection_name, rule_yaml)
897
+ else:
898
+ summary_lines.append("- Warning: rule not added to appsec-virtual-patching collection. Remember to add it before opening the PR.")
899
+
900
+ return [types.TextContent(type="text", text="\n".join(summary_lines))]
901
+
902
+
903
+ def _tool_manage_waf_stack(arguments: dict[str, Any] | None) -> list[types.TextContent]:
715
904
  try:
716
905
  if not arguments:
717
906
  LOGGER.warning("manage_waf_stack called without arguments")
@@ -732,23 +921,11 @@ def _tool_manage_waf_stack(arguments: Optional[Dict[str, Any]]) -> List[types.Te
732
921
  target_url, error_message = _start_waf_test_stack(rule_yaml)
733
922
  if error_message:
734
923
  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
- ]
924
+ raise RuntimeError(f"WAF stack start error: {error_message}")
741
925
 
742
926
  if not target_url:
743
927
  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
- ]
928
+ raise RuntimeError("WAF stack start error: stack did not return a service URL and reported no specific error.")
752
929
 
753
930
  return [
754
931
  types.TextContent(
@@ -772,19 +949,111 @@ def _tool_manage_waf_stack(arguments: Optional[Dict[str, Any]]) -> List[types.Te
772
949
  ]
773
950
 
774
951
  except Exception as exc:
775
- LOGGER.error("manage_waf_stack error: %s", exc)
952
+ LOGGER.error("manage_waf_stack error: %s", exc, exc_info=True)
953
+ raise
954
+
955
+
956
+ def _tool_run_waf_tests(arguments: dict[str, Any] | None) -> list[types.TextContent]:
957
+ stack_started_here = False
958
+ try:
959
+ if not arguments:
960
+ LOGGER.warning("run_waf_tests called without arguments")
961
+ raise ValueError("Missing arguments payload")
962
+
963
+ rule_yaml = arguments.get("rule_yaml")
964
+ nuclei_yaml = arguments.get("nuclei_yaml")
965
+
966
+ if not isinstance(rule_yaml, str) or not rule_yaml.strip():
967
+ raise ValueError("'rule_yaml' must be a non-empty string")
968
+ if not isinstance(nuclei_yaml, str) or not nuclei_yaml.strip():
969
+ raise ValueError("'nuclei_yaml' must be a non-empty string")
970
+
971
+ LOGGER.info(
972
+ "Starting WAF stack for nuclei test (image=%s, target_url=%s)",
973
+ WAF_DEFAULT_NUCLEI_IMAGE,
974
+ WAF_DEFAULT_TARGET_URL,
975
+ )
976
+
977
+ target_endpoint, stack_error = _start_waf_test_stack(rule_yaml)
978
+ if stack_error:
979
+ if "appears to be running already" in stack_error.lower():
980
+ LOGGER.info("Existing stack detected; attempting restart before running tests")
981
+ _stop_waf_test_stack()
982
+ target_endpoint, stack_error = _start_waf_test_stack(rule_yaml)
983
+ if stack_error:
984
+ LOGGER.error("Unable to start WAF stack: %s", stack_error)
985
+ raise RuntimeError(f"Unable to start WAF stack: {stack_error}")
986
+ stack_started_here = True
987
+
988
+ with tempfile.TemporaryDirectory(prefix="waf-test-") as temp_dir:
989
+ workspace = Path(temp_dir)
990
+
991
+ template_path = workspace / "nuclei-template.yaml"
992
+ template_path.parent.mkdir(parents=True, exist_ok=True)
993
+ template_path.write_text(nuclei_yaml, encoding="utf-8")
994
+
995
+ LOGGER.info(
996
+ "Running nuclei template against %s (image=%s)",
997
+ WAF_DEFAULT_TARGET_URL,
998
+ WAF_DEFAULT_NUCLEI_IMAGE,
999
+ )
1000
+ success, message = _run_nuclei_container(
1001
+ workspace,
1002
+ template_path,
1003
+ nuclei_image=WAF_DEFAULT_NUCLEI_IMAGE,
1004
+ target_url=WAF_DEFAULT_TARGET_URL,
1005
+ )
1006
+
1007
+ if not success:
1008
+ stack_logs = _collect_compose_logs(["crowdsec", "nginx"])
1009
+ parts = [
1010
+ "❌ Nuclei test failed.",
1011
+ "=== NUCLEI OUTPUT ===",
1012
+ message,
1013
+ ]
1014
+ if stack_logs:
1015
+ parts.append("=== STACK LOGS (crowdsec/nginx) ===")
1016
+ parts.append(stack_logs)
1017
+ joined = "\n\n".join(parts)
1018
+ raise RuntimeError(joined)
1019
+
1020
+ success_sections = [
1021
+ "✅ Nuclei test succeeded.",
1022
+ f"Target endpoint inside the stack: {WAF_DEFAULT_TARGET_URL}",
1023
+ f"Host accessible endpoint: {target_endpoint or 'unknown'}",
1024
+ "=== NUCLEI OUTPUT ===",
1025
+ message,
1026
+ ]
1027
+ stack_logs = _collect_compose_logs(["crowdsec", "nginx"])
1028
+ if stack_logs:
1029
+ success_sections.extend(
1030
+ [
1031
+ "=== STACK LOGS (crowdsec/nginx) ===",
1032
+ stack_logs,
1033
+ ]
1034
+ )
776
1035
  return [
777
1036
  types.TextContent(
778
1037
  type="text",
779
- text=f"❌ Stack management error: {str(exc)}",
1038
+ text="\n\n".join(success_sections),
780
1039
  )
781
1040
  ]
782
1041
 
1042
+ except Exception as exc:
1043
+ LOGGER.error("run_waf_tests error: %s", exc, exc_info=True)
1044
+ raise
1045
+ finally:
1046
+ if stack_started_here:
1047
+ try:
1048
+ _stop_waf_test_stack()
1049
+ except Exception as stop_exc: # pragma: no cover - best effort cleanup
1050
+ LOGGER.warning("Failed to stop WAF stack during cleanup: %s", stop_exc)
783
1051
 
784
- def _search_repo_for_cve(repo_path: Path, cve: str) -> List[Path]:
1052
+
1053
+ def _search_repo_for_cve(repo_path: Path, cve: str) -> list[Path]:
785
1054
  """Return files whose name contains the CVE identifier (case-insensitive)."""
786
1055
  lower_token = cve.lower()
787
- matches: List[Path] = []
1056
+ matches: list[Path] = []
788
1057
 
789
1058
  for candidate in repo_path.rglob("*"):
790
1059
  if not candidate.is_file():
@@ -795,7 +1064,7 @@ def _search_repo_for_cve(repo_path: Path, cve: str) -> List[Path]:
795
1064
  return matches
796
1065
 
797
1066
 
798
- def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
1067
+ def _tool_fetch_nuclei_exploit(arguments: dict[str, Any] | None) -> list[types.TextContent]:
799
1068
  try:
800
1069
  if not arguments:
801
1070
  LOGGER.warning("fetch_nuclei_exploit called without arguments")
@@ -814,22 +1083,20 @@ def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[type
814
1083
  target_path.mkdir(parents=True, exist_ok=True)
815
1084
 
816
1085
  LOGGER.info("Fetching nuclei exploit templates for %s", cve)
817
- findings: List[str] = []
818
- rendered_templates: List[str] = []
1086
+ findings: list[str] = []
1087
+ rendered_templates: list[str] = []
819
1088
  total_files = 0
820
1089
 
821
1090
  for repo_url in DEFAULT_EXPLOIT_REPOSITORIES:
822
1091
  cleaned_url = repo_url.rstrip("/")
823
1092
  repo_name = cleaned_url.split("/")[-1] or "repository"
824
1093
  if repo_name.endswith(".git"):
825
- repo_name = repo_name[:-4]
1094
+ repo_name = repo_name.removesuffix(".git")
826
1095
  repo_path = target_path / repo_name
827
1096
 
828
1097
  if repo_path.exists():
829
1098
  if not (repo_path / ".git").exists():
830
- raise RuntimeError(
831
- f"Destination {repo_path} exists but is not a git repository"
832
- )
1099
+ raise RuntimeError(f"Destination {repo_path} exists but is not a git repository")
833
1100
  git_cmd = ["git", "-C", str(repo_path), "pull", "--ff-only"]
834
1101
  else:
835
1102
  git_cmd = ["git", "clone", "--depth", "1", cleaned_url, str(repo_path)]
@@ -863,9 +1130,7 @@ def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[type
863
1130
  except OSError as read_err:
864
1131
  findings.append(f" (failed to read {relative_path}: {read_err})")
865
1132
  continue
866
- rendered_templates.append(
867
- f"### {cleaned_url} :: {relative_path}\n```yaml\n{file_contents}\n```"
868
- )
1133
+ rendered_templates.append(f"### {cleaned_url} :: {relative_path}\n```yaml\n{file_contents}\n```")
869
1134
  total_files += 1
870
1135
 
871
1136
  if total_files == 0:
@@ -874,10 +1139,7 @@ def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[type
874
1139
  return [
875
1140
  types.TextContent(
876
1141
  type="text",
877
- text=(
878
- f"No files containing {cve} were found in the provided repositories."
879
- f"{detail_section}"
880
- ),
1142
+ text=(f"No files containing {cve} were found in the provided repositories.{detail_section}"),
881
1143
  )
882
1144
  ]
883
1145
 
@@ -898,16 +1160,11 @@ def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[type
898
1160
  ]
899
1161
 
900
1162
  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
- ]
1163
+ LOGGER.error("fetch_nuclei_exploit error: %s", exc, exc_info=True)
1164
+ raise
908
1165
 
909
1166
 
910
- def _tool_curl_waf_endpoint(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
1167
+ def _tool_curl_waf_endpoint(arguments: dict[str, Any] | None) -> list[types.TextContent]:
911
1168
  try:
912
1169
  if not arguments:
913
1170
  LOGGER.warning("curl_waf_endpoint called without arguments")
@@ -929,9 +1186,7 @@ def _tool_curl_waf_endpoint(arguments: Optional[Dict[str, Any]]) -> List[types.T
929
1186
  if not path.startswith("/"):
930
1187
  if "://" in path:
931
1188
  parsed = urllib.parse.urlparse(path)
932
- path = urllib.parse.urlunparse(
933
- ("", "", parsed.path or "/", parsed.params, parsed.query, parsed.fragment)
934
- )
1189
+ path = urllib.parse.urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, parsed.fragment))
935
1190
  else:
936
1191
  path = "/" + path
937
1192
 
@@ -939,9 +1194,7 @@ def _tool_curl_waf_endpoint(arguments: Optional[Dict[str, Any]]) -> List[types.T
939
1194
  LOGGER.warning("curl_waf_endpoint received non-string body payload")
940
1195
  raise ValueError("'body' must be a string when provided")
941
1196
 
942
- LOGGER.info(
943
- "curl_waf_endpoint executing %s request to %s (timeout=%s)", method, path, timeout
944
- )
1197
+ LOGGER.info("curl_waf_endpoint executing %s request to %s (timeout=%s)", method, path, timeout)
945
1198
  try:
946
1199
  response = requests.request(
947
1200
  method=method,
@@ -954,12 +1207,7 @@ def _tool_curl_waf_endpoint(arguments: Optional[Dict[str, Any]]) -> List[types.T
954
1207
  raise RuntimeError(f"HTTP request failed: {req_err}") from req_err
955
1208
 
956
1209
  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
- )
1210
+ response_text = f">>> {method} http://localhost:8081{path}\nStatus: {response.status_code}\nHeaders:\n{header_lines}\n\nBody:\n{response.text}"
963
1211
 
964
1212
  LOGGER.info(
965
1213
  "curl_waf_endpoint completed with status %s for %s %s",
@@ -975,28 +1223,37 @@ def _tool_curl_waf_endpoint(arguments: Optional[Dict[str, Any]]) -> List[types.T
975
1223
  ]
976
1224
 
977
1225
  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
- ]
1226
+ LOGGER.error("curl_waf_endpoint error: %s", exc, exc_info=True)
1227
+ raise
985
1228
 
986
1229
 
987
- WAF_TOOL_HANDLERS: Dict[str, ToolHandler] = {
1230
+ WAF_TOOL_HANDLERS: dict[str, ToolHandler] = {
1231
+ "get_waf_top_level_prompt": _tool_get_waf_top_level_prompt,
988
1232
  "get_waf_prompt": _tool_get_waf_prompt,
989
1233
  "get_waf_examples": _tool_get_waf_examples,
990
1234
  "generate_waf_rule": _tool_generate_waf_rule,
1235
+ "get_waf_pr_prompt": _tool_get_waf_pr_prompt,
1236
+ "generate_waf_tests": _tool_generate_waf_tests,
991
1237
  "validate_waf_rule": _tool_validate_waf_rule,
992
1238
  "lint_waf_rule": _tool_lint_waf_rule,
993
1239
  "deploy_waf_rule": _tool_deploy_waf_rule,
1240
+ "prepare_waf_pr": _tool_prepare_waf_pr,
994
1241
  "fetch_nuclei_exploit": _tool_fetch_nuclei_exploit,
995
1242
  "manage_waf_stack": _tool_manage_waf_stack,
1243
+ "run_waf_tests": _tool_run_waf_tests,
996
1244
  "curl_waf_endpoint": _tool_curl_waf_endpoint,
997
1245
  }
998
1246
 
999
- WAF_TOOLS: List[types.Tool] = [
1247
+ WAF_TOOLS: list[types.Tool] = [
1248
+ types.Tool(
1249
+ name="get_waf_top_level_prompt",
1250
+ description="Get the top-level CrowdSec WAF workflow prompt that explains how to approach rule and test creation",
1251
+ inputSchema={
1252
+ "type": "object",
1253
+ "properties": {},
1254
+ "additionalProperties": False,
1255
+ },
1256
+ ),
1000
1257
  types.Tool(
1001
1258
  name="get_waf_prompt",
1002
1259
  description="Get the main WAF rule generation prompt for CrowdSec",
@@ -1029,6 +1286,53 @@ WAF_TOOLS: List[types.Tool] = [
1029
1286
  "additionalProperties": False,
1030
1287
  },
1031
1288
  ),
1289
+ types.Tool(
1290
+ name="generate_waf_tests",
1291
+ description="Get the WAF test generation prompt for producing config.yaml and adapted Nuclei templates",
1292
+ inputSchema={
1293
+ "type": "object",
1294
+ "properties": {
1295
+ "nuclei_template": {
1296
+ "type": "string",
1297
+ "description": "Optional Nuclei template to include so the assistant can adapt it for testing",
1298
+ },
1299
+ "rule_filename": {
1300
+ "type": "string",
1301
+ "description": "Optional path to the generated rule (e.g. ./appsec-rules/crowdsecurity/vpatch-CVE-XXXX-YYYY.yaml)",
1302
+ },
1303
+ },
1304
+ "additionalProperties": False,
1305
+ },
1306
+ ),
1307
+ types.Tool(
1308
+ name="get_waf_pr_prompt",
1309
+ description="Get the WAF PR preparation prompt for writing test assets and drafting a PR comment",
1310
+ inputSchema={
1311
+ "type": "object",
1312
+ "properties": {},
1313
+ "additionalProperties": False,
1314
+ },
1315
+ ),
1316
+ types.Tool(
1317
+ name="run_waf_tests",
1318
+ description="Start the WAF harness and execute the provided nuclei test template against it."
1319
+ " If this action fails because docker isn't present or cannot be run, prompt the user to set it up manually.",
1320
+ inputSchema={
1321
+ "type": "object",
1322
+ "properties": {
1323
+ "rule_yaml": {
1324
+ "type": "string",
1325
+ "description": "CrowdSec WAF rule YAML to load into the harness before running tests",
1326
+ },
1327
+ "nuclei_yaml": {
1328
+ "type": "string",
1329
+ "description": "Adapted nuclei template YAML that should trigger a block (HTTP 403)",
1330
+ },
1331
+ },
1332
+ "required": ["rule_yaml", "nuclei_yaml"],
1333
+ "additionalProperties": False,
1334
+ },
1335
+ ),
1032
1336
  types.Tool(
1033
1337
  name="validate_waf_rule",
1034
1338
  description="Validate that a CrowdSec WAF rule YAML is syntactically correct",
@@ -1068,6 +1372,52 @@ WAF_TOOLS: List[types.Tool] = [
1068
1372
  "additionalProperties": False,
1069
1373
  },
1070
1374
  ),
1375
+ types.Tool(
1376
+ name="prepare_waf_pr",
1377
+ description=("Help prepare a pull request in a local clone of the CrowdSec hub repository by adding the generated WAF rule and associated test files."),
1378
+ inputSchema={
1379
+ "type": "object",
1380
+ "properties": {
1381
+ "hub_dir": {
1382
+ "type": "string",
1383
+ "description": "Path to the local clone of the CrowdSec hub repository",
1384
+ },
1385
+ "rule_yaml": {
1386
+ "type": "string",
1387
+ "description": "Generated WAF rule YAML content",
1388
+ },
1389
+ "test_config_yaml": {
1390
+ "type": "string",
1391
+ "description": "Generated AppSec test config.yaml content",
1392
+ },
1393
+ "test_nuclei_yaml": {
1394
+ "type": "string",
1395
+ "description": "Generated adapted nuclei template content",
1396
+ },
1397
+ "rule_filename": {
1398
+ "type": "string",
1399
+ "description": "Relative path (from hub root) for the rule file",
1400
+ },
1401
+ "nuclei_filename": {
1402
+ "type": "string",
1403
+ "description": "Filename for the nuclei test template",
1404
+ },
1405
+ "collection_name": {
1406
+ "type": "string",
1407
+ "description": "Target collection in the format author/name (mapped to collections/author/name.yaml)",
1408
+ },
1409
+ },
1410
+ "required": [
1411
+ "hub_dir",
1412
+ "rule_yaml",
1413
+ "test_config_yaml",
1414
+ "test_nuclei_yaml",
1415
+ "rule_filename",
1416
+ "nuclei_filename",
1417
+ ],
1418
+ "additionalProperties": False,
1419
+ },
1420
+ ),
1071
1421
  types.Tool(
1072
1422
  name="fetch_nuclei_exploit",
1073
1423
  description="Retrieve nuclei templates from the official repository for a CVE to help with generation of WAF rules",
@@ -1085,7 +1435,8 @@ WAF_TOOLS: List[types.Tool] = [
1085
1435
  ),
1086
1436
  types.Tool(
1087
1437
  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",
1438
+ description="Start or stop the Docker-based CrowdSec AppSec test stack so the rule can be exercised with allowed and blocked requests."
1439
+ " If this action fails because docker isn't present or cannot be run, prompt the user to set it up manually.",
1089
1440
  inputSchema={
1090
1441
  "type": "object",
1091
1442
  "properties": {
@@ -1139,7 +1490,13 @@ WAF_TOOLS: List[types.Tool] = [
1139
1490
  ),
1140
1491
  ]
1141
1492
 
1142
- WAF_RESOURCES: List[types.Resource] = [
1493
+ WAF_RESOURCES: list[types.Resource] = [
1494
+ types.Resource(
1495
+ uri="file://prompts/prompt-waf-top-level.txt",
1496
+ name="WAF Top-Level Workflow Prompt",
1497
+ description="High-level guidance for handling CrowdSec WAF rule requests and which tools to use",
1498
+ mimeType="text/plain",
1499
+ ),
1143
1500
  types.Resource(
1144
1501
  uri="file://prompts/prompt-waf.txt",
1145
1502
  name="WAF Rule Generation Prompt",
@@ -1158,12 +1515,27 @@ WAF_RESOURCES: List[types.Resource] = [
1158
1515
  description="Step-by-step guide for deploying CrowdSec WAF rules",
1159
1516
  mimeType="text/plain",
1160
1517
  ),
1518
+ types.Resource(
1519
+ uri="file://prompts/prompt-waf-tests.txt",
1520
+ name="WAF Test Generation Prompt",
1521
+ description="Instructions for producing config.yaml and adapted Nuclei templates for WAF testing",
1522
+ mimeType="text/plain",
1523
+ ),
1524
+ types.Resource(
1525
+ uri="file://prompts/prompt-waf-pr.txt",
1526
+ name="WAF PR Preparation Prompt",
1527
+ description="Short guidance for preparing WAF PR assets and drafting a PR comment",
1528
+ mimeType="text/plain",
1529
+ ),
1161
1530
  ]
1162
1531
 
1163
- WAF_RESOURCE_READERS: Dict[str, Callable[[], str]] = {
1532
+ WAF_RESOURCE_READERS: dict[str, Callable[[], str]] = {
1533
+ "file://prompts/prompt-waf-top-level.txt": lambda: WAF_TOP_LEVEL_PROMPT_FILE.read_text(encoding="utf-8"),
1164
1534
  "file://prompts/prompt-waf.txt": lambda: WAF_PROMPT_FILE.read_text(encoding="utf-8"),
1165
1535
  "file://prompts/prompt-waf-examples.txt": lambda: WAF_EXAMPLES_FILE.read_text(encoding="utf-8"),
1166
1536
  "file://prompts/prompt-waf-deploy.txt": lambda: WAF_DEPLOY_FILE.read_text(encoding="utf-8"),
1537
+ "file://prompts/prompt-waf-tests.txt": lambda: WAF_TESTS_PROMPT_FILE.read_text(encoding="utf-8"),
1538
+ "file://prompts/prompt-waf-pr.txt": lambda: WAF_PR_PROMPT_FILE.read_text(encoding="utf-8"),
1167
1539
  }
1168
1540
 
1169
1541
  REGISTRY.register_tools(WAF_TOOL_HANDLERS, WAF_TOOLS)