crowdsec-local-mcp 0.2.0__py3-none-any.whl → 0.7.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 (26) 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 +3 -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 +567 -337
  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-tests.txt +101 -0
  16. crowdsec_local_mcp/prompts/prompt-waf-top-level.txt +31 -0
  17. crowdsec_local_mcp/prompts/prompt-waf.txt +0 -26
  18. crowdsec_local_mcp/setup_cli.py +98 -29
  19. crowdsec_local_mcp-0.7.0.post1.dev0.dist-info/METADATA +114 -0
  20. crowdsec_local_mcp-0.7.0.post1.dev0.dist-info/RECORD +38 -0
  21. crowdsec_local_mcp-0.2.0.dist-info/METADATA +0 -74
  22. crowdsec_local_mcp-0.2.0.dist-info/RECORD +0 -31
  23. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/WHEEL +0 -0
  24. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/entry_points.txt +0 -0
  25. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/licenses/LICENSE +0 -0
  26. {crowdsec_local_mcp-0.2.0.dist-info → crowdsec_local_mcp-0.7.0.post1.dev0.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,33 @@
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"
18
31
 
19
32
  CROWDSEC_SCHEMAS_DIR = SCRIPT_DIR / "yaml-schemas"
20
33
  WAF_SCHEMA_FILE = CROWDSEC_SCHEMAS_DIR / "appsec_rules_schema.yaml"
@@ -36,6 +49,9 @@ WAF_TEST_APPSEC_CONFIG = (
36
49
  )
37
50
  WAF_RULE_NAME_PLACEHOLDER = "__PLACEHOLDER_FOR_USER_RULE__"
38
51
  WAF_TEST_PROJECT_NAME = "crowdsec-mcp-waf"
52
+ WAF_TEST_NETWORK_NAME = f"{WAF_TEST_PROJECT_NAME}_waf-net"
53
+ WAF_DEFAULT_TARGET_URL = "http://nginx-appsec"
54
+ WAF_DEFAULT_NUCLEI_IMAGE = "projectdiscovery/nuclei:latest"
39
55
 
40
56
  DEFAULT_EXPLOIT_REPOSITORIES = [
41
57
  "https://github.com/projectdiscovery/nuclei-templates.git",
@@ -45,45 +61,11 @@ DEFAULT_EXPLOIT_TARGET_DIR = SCRIPT_DIR / "cached-exploits"
45
61
  CASE_SENSITIVE_MATCH_TYPES = ["regex", "contains", "startsWith", "endsWith", "equals"]
46
62
  SQL_KEYWORD_INDICATORS = ["union", "select", "insert", "update", "delete", "drop"]
47
63
 
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
- )
64
+ _COMPOSE_STACK_PROCESS: subprocess.Popen | None = None
83
65
 
84
66
 
85
- def _collect_compose_logs(services: Optional[List[str]] = None, tail_lines: int = 200) -> str:
86
- cmd = _detect_compose_command() + [
67
+ def _collect_compose_logs(services: list[str] | None = None, tail_lines: int = 200) -> str:
68
+ cmd = ensure_docker_compose_cli() + [
87
69
  "-p",
88
70
  WAF_TEST_PROJECT_NAME,
89
71
  "-f",
@@ -118,10 +100,10 @@ def _collect_compose_logs(services: Optional[List[str]] = None, tail_lines: int
118
100
 
119
101
 
120
102
  def _run_compose_command(
121
- args: List[str], capture_output: bool = True, check: bool = True
103
+ args: list[str], capture_output: bool = True, check: bool = True
122
104
  ) -> subprocess.CompletedProcess:
123
105
  """Run a docker compose command inside the WAF test harness directory."""
124
- base_cmd = _detect_compose_command()
106
+ base_cmd = ensure_docker_compose_cli()
125
107
  full_cmd = base_cmd + ["-p", WAF_TEST_PROJECT_NAME, "-f", str(WAF_TEST_COMPOSE_FILE)] + args
126
108
  LOGGER.info("Executing compose command: %s", " ".join(full_cmd))
127
109
 
@@ -133,9 +115,12 @@ def _run_compose_command(
133
115
  capture_output=capture_output,
134
116
  text=True,
135
117
  )
136
- except FileNotFoundError as error:
118
+ except (FileNotFoundError, PermissionError) as error:
137
119
  LOGGER.error("Compose command failed to start: %s", error)
138
- raise RuntimeError(f"Failed to run {' '.join(base_cmd)}: {error}") from error
120
+ raise RuntimeError(
121
+ "Docker Compose is required but could not be executed. "
122
+ "Install Docker and ensure the current user can run `docker compose` commands."
123
+ ) from error
139
124
  except subprocess.CalledProcessError as error:
140
125
  stdout = (error.stdout or "").strip()
141
126
  stderr = (error.stderr or "").strip()
@@ -153,7 +138,7 @@ def _run_compose_command(
153
138
 
154
139
 
155
140
  def _run_compose_exec(
156
- args: List[str], capture_output: bool = True, check: bool = True
141
+ args: list[str], capture_output: bool = True, check: bool = True
157
142
  ) -> subprocess.CompletedProcess:
158
143
  """Run docker compose exec against the CrowdSec container."""
159
144
  exec_args = ["exec", "-T"] + args
@@ -217,7 +202,119 @@ def _wait_for_crowdsec_ready(timeout: int = 90) -> None:
217
202
  raise RuntimeError("CrowdSec local API did not become ready in time")
218
203
 
219
204
 
220
- def _start_waf_test_stack(rule_yaml: str) -> Tuple[Optional[str], Optional[str]]:
205
+ def _run_nuclei_container(
206
+ workspace: Path,
207
+ template_path: Path,
208
+ *,
209
+ nuclei_image: str,
210
+ target_url: str,
211
+ nuclei_args: list[str] | None = None,
212
+ timeout: int = 180,
213
+ ) -> tuple[bool, str]:
214
+ """Run the provided nuclei template inside a disposable docker container."""
215
+ rel_template = template_path.relative_to(workspace)
216
+ container_template_path = f"/nuclei/{rel_template.as_posix()}"
217
+
218
+ ensure_docker_cli()
219
+
220
+ command = [
221
+ "docker",
222
+ "run",
223
+ "--rm",
224
+ "--network",
225
+ WAF_TEST_NETWORK_NAME,
226
+ "-v",
227
+ f"{workspace}:/nuclei",
228
+ nuclei_image,
229
+ "-t",
230
+ container_template_path,
231
+ "-u",
232
+ target_url,
233
+ "-jsonl",
234
+ "-silent",
235
+ ]
236
+ if nuclei_args:
237
+ command.extend(str(arg) for arg in nuclei_args)
238
+
239
+ LOGGER.info("Executing nuclei container: %s", " ".join(command))
240
+
241
+ try:
242
+ result = subprocess.run(
243
+ command,
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=timeout,
247
+ check=False,
248
+ )
249
+ except subprocess.TimeoutExpired:
250
+ LOGGER.error("Nuclei container timed out after %s seconds", timeout)
251
+ return (
252
+ False,
253
+ "Nuclei execution timed out. Consider simplifying the template or increasing the timeout.",
254
+ )
255
+
256
+ stdout = (result.stdout or "").strip()
257
+ stderr = (result.stderr or "").strip()
258
+ details: list[str] = []
259
+ if stdout:
260
+ details.append(f"stdout:\n{stdout}")
261
+ if stderr:
262
+ details.append(f"stderr:\n{stderr}")
263
+ detail_text = "\n\n".join(details)
264
+
265
+ if result.returncode != 0:
266
+ LOGGER.error("Nuclei container exited with code %s", result.returncode)
267
+ failure = (
268
+ f"Nuclei container exited with status {result.returncode}."
269
+ + (f"\n\n{detail_text}" if detail_text else "")
270
+ )
271
+ return (False, failure)
272
+
273
+ matches: list[dict[str, Any]] = []
274
+ unmatched_lines: list[str] = []
275
+ for line in stdout.splitlines():
276
+ if not line.strip():
277
+ continue
278
+ try:
279
+ payload = json.loads(line)
280
+ if isinstance(payload, dict):
281
+ matches.append(payload)
282
+ else:
283
+ unmatched_lines.append(line)
284
+ except json.JSONDecodeError:
285
+ unmatched_lines.append(line)
286
+
287
+ if not matches:
288
+ LOGGER.warning("Nuclei execution completed but no matches were reported")
289
+ info_lines = []
290
+ if unmatched_lines:
291
+ info_lines.append("Nuclei produced output but no matches were recorded:\n" + "\n".join(unmatched_lines))
292
+ else:
293
+ info_lines.append(
294
+ "Nuclei completed successfully but reported zero matches. "
295
+ "The WAF rule likely did not block the request (missing HTTP 403)."
296
+ )
297
+ if stderr:
298
+ info_lines.append(f"stderr:\n{stderr}")
299
+ return (False, "\n\n".join(info_lines))
300
+
301
+ summary_lines = [
302
+ f"Nuclei reported {len(matches)} match(es) using template {rel_template.name}.",
303
+ ]
304
+ for match in matches:
305
+ template_id = match.get("template-id") or match.get("templateID") or rel_template.stem
306
+ url = match.get("matched-at") or match.get("matchedAt") or target_url
307
+ summary_lines.append(f" - {template_id} matched at {url}")
308
+ if unmatched_lines:
309
+ summary_lines.append(
310
+ "Additional nuclei output:\n" + "\n".join(unmatched_lines)
311
+ )
312
+ if stderr:
313
+ summary_lines.append(f"stderr:\n{stderr}")
314
+ return (True, "\n".join(summary_lines))
315
+
316
+
317
+ def _start_waf_test_stack(rule_yaml: str) -> tuple[str | None, str | None]:
221
318
  global _COMPOSE_STACK_PROCESS
222
319
  LOGGER.info("Starting WAF test stack")
223
320
  if not WAF_TEST_COMPOSE_FILE.exists():
@@ -289,7 +386,7 @@ def _start_waf_test_stack(rule_yaml: str) -> Tuple[Optional[str], Optional[str]]
289
386
  _teardown_compose_stack(check=False)
290
387
  return (None, f"{error}{log_section}")
291
388
 
292
- compose_base = _detect_compose_command() + [
389
+ compose_base = ensure_docker_compose_cli() + [
293
390
  "-p",
294
391
  WAF_TEST_PROJECT_NAME,
295
392
  "-f",
@@ -306,11 +403,12 @@ def _start_waf_test_stack(rule_yaml: str) -> Tuple[Optional[str], Optional[str]]
306
403
  stdout=subprocess.DEVNULL,
307
404
  stderr=subprocess.STDOUT,
308
405
  )
309
- except FileNotFoundError:
406
+ LOGGER.info("Launched docker compose process with PID %s", process.pid)
407
+ except (FileNotFoundError, PermissionError):
310
408
  LOGGER.error("Failed to launch docker compose process")
311
409
  return (
312
410
  None,
313
- "Docker Compose is required but could not be executed. Ensure Docker is installed and available.",
411
+ "Docker Compose is required but could not be executed. Ensure Docker is installed and the current user can run Docker commands.",
314
412
  )
315
413
 
316
414
  _COMPOSE_STACK_PROCESS = process
@@ -336,226 +434,225 @@ def _stop_waf_test_stack() -> None:
336
434
  _teardown_compose_stack(check=True)
337
435
 
338
436
 
339
- def _validate_waf_rule(rule_yaml: str) -> List[types.TextContent]:
437
+ def _validate_waf_rule(rule_yaml: str) -> list[types.TextContent]:
340
438
  """Validate that a CrowdSec WAF rule YAML conforms to the schema."""
341
439
  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
- ]
440
+ if not WAF_SCHEMA_FILE.exists():
441
+ LOGGER.error("Schema file missing at %s", WAF_SCHEMA_FILE)
442
+ raise FileNotFoundError(f"Schema file {WAF_SCHEMA_FILE} not found")
351
443
 
444
+ try:
352
445
  schema = yaml.safe_load(WAF_SCHEMA_FILE.read_text(encoding="utf-8"))
446
+ except yaml.YAMLError as exc:
447
+ LOGGER.error("Failed to parse WAF schema YAML: %s", exc)
448
+ raise ValueError(f"Unable to parse WAF schema YAML: {exc!s}") from exc
449
+
450
+ try:
353
451
  parsed = yaml.safe_load(rule_yaml)
452
+ except yaml.YAMLError as exc:
453
+ LOGGER.error("YAML syntax error during validation: %s", exc)
454
+ raise ValueError(f"YAML syntax error: {exc!s}") from exc
354
455
 
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
- ]
456
+ if parsed is None:
457
+ LOGGER.warning("Validation request received empty YAML content")
458
+ raise ValueError("Empty or invalid YAML content")
363
459
 
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
- ]
460
+ if not isinstance(parsed, dict):
461
+ raise ValueError("YAML must be a dictionary/object")
371
462
 
463
+ try:
372
464
  jsonschema.validate(instance=parsed, schema=schema)
465
+ except jsonschema.ValidationError as exc:
466
+ error_path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "root"
467
+ LOGGER.warning("Schema validation error at %s: %s", error_path, exc.message)
468
+ raise ValueError(f"Schema validation error at {error_path}: {exc.message}") from exc
469
+ except jsonschema.SchemaError as exc:
470
+ LOGGER.error("Invalid schema encountered: %s", exc)
471
+ raise RuntimeError(f"Invalid schema: {exc!s}") from exc
472
+
473
+ LOGGER.info("WAF rule validation passed")
474
+ return [
475
+ types.TextContent(
476
+ type="text",
477
+ text="✅ VALIDATION PASSED: Rule conforms to CrowdSec AppSec schema",
478
+ )
479
+ ]
373
480
 
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
481
 
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)}",
482
+ def _analyze_rule_item(
483
+ rule_item: Any, rule_path: str, warnings: list[str]
484
+ ) -> tuple[bool, bool]:
485
+ """Recursively inspect rule items, track operator usage, and record warnings."""
486
+ if not isinstance(rule_item, dict):
487
+ return (False, False)
488
+
489
+ location = f"rules{rule_path}" if rule_path else "rules"
490
+ has_and = "and" in rule_item
491
+ has_or = "or" in rule_item
492
+ contains_and = has_and
493
+ contains_or = has_or
494
+
495
+ if has_and and has_or:
496
+ warnings.append(
497
+ f"{location} mixes 'and' and 'or' operators at the same level; split them into separate nested blocks"
498
+ )
499
+
500
+ if has_and:
501
+ for i, sub_rule in enumerate(rule_item["and"]):
502
+ child_and, child_or = _analyze_rule_item(
503
+ sub_rule,
504
+ f"{rule_path}.and[{i}]",
505
+ warnings,
405
506
  )
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)}",
507
+ contains_and = contains_and or child_and
508
+ contains_or = contains_or or child_or
509
+
510
+ if has_or:
511
+ for i, sub_rule in enumerate(rule_item["or"]):
512
+ child_and, child_or = _analyze_rule_item(
513
+ sub_rule,
514
+ f"{rule_path}.or[{i}]",
515
+ warnings,
413
516
  )
414
- ]
517
+ contains_and = contains_and or child_and
518
+ contains_or = contains_or or child_or
519
+
520
+ if "match" in rule_item and not (has_and or has_or):
521
+ match = rule_item["match"]
522
+ if isinstance(match, dict):
523
+ match_type = match.get("type", "")
524
+ match_value = match.get("value", "")
525
+
526
+ if (
527
+ match_type in CASE_SENSITIVE_MATCH_TYPES
528
+ and isinstance(match_value, str)
529
+ and any(c.isupper() for c in match_value)
530
+ ):
531
+ transforms = rule_item.get("transform", [])
532
+ has_lowercase = (
533
+ "lowercase" in transforms if isinstance(transforms, list) else False
534
+ )
535
+
536
+ if not has_lowercase:
537
+ warnings.append(
538
+ f"Match at {location} uses '{match_type}' with uppercase letters "
539
+ f"but no 'lowercase' transform - consider adding lowercase transform for case-insensitive matching"
540
+ )
415
541
 
542
+ if isinstance(match_value, str):
543
+ lower_value = match_value.lower()
544
+ sql_keywords = [kw for kw in SQL_KEYWORD_INDICATORS if kw in lower_value]
545
+ if sql_keywords:
546
+ keywords_str = ", ".join(sorted(set(sql_keywords)))
547
+ warnings.append(
548
+ f"Match at {location} contains SQL keyword(s) ({keywords_str}); instead of keyword blacklisting, detect escaping characters like quotes or semicolons"
549
+ )
550
+
551
+ transforms = rule_item.get("transform", [])
552
+ if isinstance(transforms, list) and "urldecode" in transforms:
553
+ if "%" in match_value:
554
+ warnings.append(
555
+ f"Match at {location} applies 'urldecode' but still contains percent-encoded characters; ensure the value is properly decoded or add another urldecode pass."
556
+ )
416
557
 
417
- def _lint_waf_rule(rule_yaml: str) -> List[types.TextContent]:
558
+ return (contains_and, contains_or)
559
+
560
+
561
+ def lint_waf_rule(rule_yaml: str) -> list[types.TextContent]:
418
562
  """Lint a CrowdSec WAF rule and provide warnings/hints for improvement."""
419
563
  LOGGER.info("Linting WAF rule YAML (size=%s bytes)", len(rule_yaml.encode("utf-8")))
420
564
  try:
421
565
  parsed = yaml.safe_load(rule_yaml)
566
+ except yaml.YAMLError as exc:
567
+ LOGGER.error("Lint failed due to YAML error: %s", exc)
568
+ raise ValueError(f"Cannot lint invalid YAML: {exc!s}") from exc
422
569
 
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
- ]
570
+ if parsed is None:
571
+ LOGGER.warning("Lint request failed: YAML content was empty or invalid")
572
+ raise ValueError("Cannot lint empty or invalid YAML")
431
573
 
432
- warnings: List[str] = []
433
- hints: List[str] = []
574
+ warnings: list[str] = []
575
+ hints: list[str] = []
434
576
 
435
- if not isinstance(parsed, dict):
436
- warnings.append("Rule should be a YAML dictionary")
577
+ if not isinstance(parsed, dict):
578
+ warnings.append("Rule should be a YAML dictionary")
437
579
 
438
- if "name" not in parsed:
439
- warnings.append("Missing 'name' field")
580
+ if "name" not in parsed:
581
+ warnings.append("Missing 'name' field")
440
582
 
441
- if "rules" not in parsed:
442
- warnings.append("Missing 'rules' field")
583
+ if "rules" not in parsed:
584
+ warnings.append("Missing 'rules' field")
443
585
 
444
- if "labels" not in parsed:
445
- warnings.append("Missing 'labels' field")
586
+ if "labels" not in parsed:
587
+ warnings.append("Missing 'labels' field")
446
588
 
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")
589
+ if "name" in parsed:
590
+ name = parsed.get("name", "")
591
+ if isinstance(name, str):
592
+ if name.startswith("crowdsecurity/"):
593
+ warnings.append(
594
+ "Rule name starts with 'crowdsecurity/' which is reserved for official CrowdSec rules; consider using your own namespace"
595
+ )
596
+ else:
597
+ warnings.append("Field 'name' should be a string")
598
+
599
+ if "rules" in parsed and isinstance(parsed["rules"], list):
600
+ for i, rule in enumerate(parsed["rules"]):
601
+ rule_has_and, rule_has_or = _analyze_rule_item(rule, f"[{i}]", warnings)
602
+ if rule_has_and and rule_has_or:
603
+ warnings.append(
604
+ 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"
605
+ )
456
606
 
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
607
+ result_lines: list[str] = []
461
608
 
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
- )
609
+ if not warnings and not hints:
610
+ result_lines.append("✅ LINT PASSED: No issues found")
611
+ LOGGER.info("Lint completed with no findings")
612
+ else:
613
+ if warnings:
614
+ result_lines.append("⚠️ WARNINGS:")
615
+ for warning in warnings:
616
+ result_lines.append(f" - {warning}")
617
+ LOGGER.warning("Lint completed with %s warning(s)", len(warnings))
483
618
 
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:
619
+ if hints:
519
620
  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))
621
+ result_lines.append("")
622
+ result_lines.append("💡 HINTS:")
623
+ for hint in hints:
624
+ result_lines.append(f" - {hint}")
625
+ LOGGER.info("Lint completed with %s hint(s)", len(hints))
626
+
627
+ return [
628
+ types.TextContent(
629
+ type="text",
630
+ text="\n".join(result_lines),
631
+ )
632
+ ]
532
633
 
533
- return [
534
- types.TextContent(
535
- type="text",
536
- text="\n".join(result_lines),
537
- )
538
- ]
539
634
 
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)
635
+ def _tool_get_waf_top_level_prompt(_: dict[str, Any] | None) -> list[types.TextContent]:
636
+ try:
637
+ LOGGER.info("Serving WAF top-level orchestration prompt content")
638
+ prompt_content = WAF_TOP_LEVEL_PROMPT_FILE.read_text(encoding="utf-8")
550
639
  return [
551
640
  types.TextContent(
552
641
  type="text",
553
- text=f"❌ LINT ERROR: Unexpected error: {str(e)}",
642
+ text=prompt_content,
554
643
  )
555
644
  ]
645
+ except FileNotFoundError as exc:
646
+ LOGGER.error("WAF top-level prompt file not found at %s", WAF_TOP_LEVEL_PROMPT_FILE)
647
+ raise FileNotFoundError(
648
+ f"WAF top-level prompt file not found at {WAF_TOP_LEVEL_PROMPT_FILE}"
649
+ ) from exc
650
+ except Exception as exc:
651
+ LOGGER.error("Error loading WAF top-level prompt: %s", exc)
652
+ raise RuntimeError(f"Error reading WAF top-level prompt file: {exc!s}") from exc
556
653
 
557
654
 
558
- def _tool_get_waf_prompt(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
655
+ def _tool_get_waf_prompt(_: dict[str, Any] | None) -> list[types.TextContent]:
559
656
  try:
560
657
  LOGGER.info("Serving WAF prompt content")
561
658
  prompt_content = WAF_PROMPT_FILE.read_text(encoding="utf-8")
@@ -565,25 +662,15 @@ def _tool_get_waf_prompt(_: Optional[Dict[str, Any]]) -> List[types.TextContent]
565
662
  text=prompt_content,
566
663
  )
567
664
  ]
568
- except FileNotFoundError:
665
+ except FileNotFoundError as exc:
569
666
  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
- ]
667
+ raise FileNotFoundError(f"WAF prompt file not found at {WAF_PROMPT_FILE}") from exc
576
668
  except Exception as exc:
577
669
  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
- ]
670
+ raise RuntimeError(f"Error reading WAF prompt file: {exc!s}") from exc
584
671
 
585
672
 
586
- def _tool_get_waf_examples(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
673
+ def _tool_get_waf_examples(_: dict[str, Any] | None) -> list[types.TextContent]:
587
674
  try:
588
675
  LOGGER.info("Serving WAF examples content")
589
676
  examples_content = WAF_EXAMPLES_FILE.read_text(encoding="utf-8")
@@ -593,25 +680,15 @@ def _tool_get_waf_examples(_: Optional[Dict[str, Any]]) -> List[types.TextConten
593
680
  text=examples_content,
594
681
  )
595
682
  ]
596
- except FileNotFoundError:
683
+ except FileNotFoundError as exc:
597
684
  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
- ]
685
+ raise FileNotFoundError(f"WAF examples file not found at {WAF_EXAMPLES_FILE}") from exc
604
686
  except Exception as exc:
605
687
  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
- ]
688
+ raise RuntimeError(f"Error reading WAF examples file: {exc!s}") from exc
612
689
 
613
690
 
614
- def _tool_generate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
691
+ def _tool_generate_waf_rule(arguments: dict[str, Any] | None) -> list[types.TextContent]:
615
692
  try:
616
693
  main_prompt = WAF_PROMPT_FILE.read_text(encoding="utf-8")
617
694
  examples_prompt = WAF_EXAMPLES_FILE.read_text(encoding="utf-8")
@@ -637,53 +714,80 @@ def _tool_generate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.T
637
714
  ]
638
715
  except FileNotFoundError as exc:
639
716
  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
- ]
717
+ raise FileNotFoundError(f"Prompt file not found: {exc!s}") from exc
646
718
  except Exception as exc:
647
719
  LOGGER.error("Unexpected error generating WAF prompt: %s", exc)
720
+ raise RuntimeError(f"Error generating WAF rule prompt: {exc!s}") from exc
721
+
722
+
723
+ def _tool_generate_waf_tests(arguments: dict[str, Any] | None) -> list[types.TextContent]:
724
+ try:
725
+ tests_prompt = WAF_TESTS_PROMPT_FILE.read_text(encoding="utf-8")
726
+ nuclei_template = arguments.get("nuclei_template") if arguments else None
727
+ rule_filename = arguments.get("rule_filename") if arguments else None
728
+
729
+ LOGGER.info(
730
+ "Generating WAF test prompt (nuclei_template_present=%s, rule_filename_present=%s)",
731
+ bool(nuclei_template),
732
+ bool(rule_filename),
733
+ )
734
+
735
+ combined_prompt = tests_prompt
736
+
737
+ if rule_filename:
738
+ combined_prompt += (
739
+ "\n\n### Rule Under Test\n"
740
+ f"The detection rule produced earlier is stored at: {rule_filename}\n"
741
+ "Use this exact path in the config.yaml `appsec-rules` list."
742
+ )
743
+
744
+ if nuclei_template:
745
+ combined_prompt += (
746
+ "\n\n### Input Nuclei Template to Adapt:\n"
747
+ f"```yaml\n{nuclei_template}\n```"
748
+ )
749
+
648
750
  return [
649
751
  types.TextContent(
650
752
  type="text",
651
- text=f"Error generating WAF rule prompt: {str(exc)}",
753
+ text=combined_prompt,
652
754
  )
653
755
  ]
756
+ except FileNotFoundError as exc:
757
+ LOGGER.error("WAF test prompt missing: %s", exc)
758
+ raise FileNotFoundError(f"WAF test prompt file not found: {exc!s}") from exc
759
+ except Exception as exc:
760
+ LOGGER.error("Unexpected error generating WAF test prompt: %s", exc)
761
+ raise RuntimeError(f"Error generating WAF test prompt: {exc!s}") from exc
654
762
 
655
763
 
656
- def _tool_validate_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
764
+ def _tool_validate_waf_rule(arguments: dict[str, Any] | None) -> list[types.TextContent]:
657
765
  if not arguments or "rule_yaml" not in arguments:
658
766
  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
- ]
767
+ raise ValueError("rule_yaml parameter is required")
665
768
 
666
769
  rule_yaml = arguments["rule_yaml"]
770
+ if not isinstance(rule_yaml, str):
771
+ raise TypeError("rule_yaml must be provided as a string")
772
+
667
773
  LOGGER.info("Received validation request for WAF rule")
668
774
  return _validate_waf_rule(rule_yaml)
669
775
 
670
776
 
671
- def _tool_lint_waf_rule(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
777
+ def _tool_lint_waf_rule(arguments: dict[str, Any] | None) -> list[types.TextContent]:
672
778
  if not arguments or "rule_yaml" not in arguments:
673
779
  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
- ]
780
+ raise ValueError("rule_yaml parameter is required")
680
781
 
681
782
  rule_yaml = arguments["rule_yaml"]
783
+ if not isinstance(rule_yaml, str):
784
+ raise TypeError("rule_yaml must be provided as a string")
785
+
682
786
  LOGGER.info("Received lint request for WAF rule")
683
- return _lint_waf_rule(rule_yaml)
787
+ return lint_waf_rule(rule_yaml)
684
788
 
685
789
 
686
- def _tool_deploy_waf_rule(_: Optional[Dict[str, Any]]) -> List[types.TextContent]:
790
+ def _tool_deploy_waf_rule(_: dict[str, Any] | None) -> list[types.TextContent]:
687
791
  try:
688
792
  LOGGER.info("Serving WAF deployment guide content")
689
793
  deploy_content = WAF_DEPLOY_FILE.read_text(encoding="utf-8")
@@ -693,25 +797,15 @@ def _tool_deploy_waf_rule(_: Optional[Dict[str, Any]]) -> List[types.TextContent
693
797
  text=deploy_content,
694
798
  )
695
799
  ]
696
- except FileNotFoundError:
800
+ except FileNotFoundError as exc:
697
801
  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
- ]
802
+ raise FileNotFoundError(f"WAF deployment guide file not found at {WAF_DEPLOY_FILE}") from exc
704
803
  except Exception as exc:
705
804
  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
- ]
805
+ raise RuntimeError(f"Error reading WAF deployment guide: {exc!s}") from exc
712
806
 
713
807
 
714
- def _tool_manage_waf_stack(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
808
+ def _tool_manage_waf_stack(arguments: dict[str, Any] | None) -> list[types.TextContent]:
715
809
  try:
716
810
  if not arguments:
717
811
  LOGGER.warning("manage_waf_stack called without arguments")
@@ -732,23 +826,13 @@ def _tool_manage_waf_stack(arguments: Optional[Dict[str, Any]]) -> List[types.Te
732
826
  target_url, error_message = _start_waf_test_stack(rule_yaml)
733
827
  if error_message:
734
828
  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
- ]
829
+ raise RuntimeError(f"WAF stack start error: {error_message}")
741
830
 
742
831
  if not target_url:
743
832
  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
- ]
833
+ raise RuntimeError(
834
+ "WAF stack start error: stack did not return a service URL and reported no specific error."
835
+ )
752
836
 
753
837
  return [
754
838
  types.TextContent(
@@ -772,19 +856,110 @@ def _tool_manage_waf_stack(arguments: Optional[Dict[str, Any]]) -> List[types.Te
772
856
  ]
773
857
 
774
858
  except Exception as exc:
775
- LOGGER.error("manage_waf_stack error: %s", exc)
859
+ LOGGER.error("manage_waf_stack error: %s", exc, exc_info=True)
860
+ raise
861
+
862
+
863
+ def _tool_run_waf_tests(arguments: dict[str, Any] | None) -> list[types.TextContent]:
864
+ stack_started_here = False
865
+ try:
866
+ if not arguments:
867
+ LOGGER.warning("run_waf_tests called without arguments")
868
+ raise ValueError("Missing arguments payload")
869
+
870
+ rule_yaml = arguments.get("rule_yaml")
871
+ nuclei_yaml = arguments.get("nuclei_yaml")
872
+
873
+ if not isinstance(rule_yaml, str) or not rule_yaml.strip():
874
+ raise ValueError("'rule_yaml' must be a non-empty string")
875
+ if not isinstance(nuclei_yaml, str) or not nuclei_yaml.strip():
876
+ raise ValueError("'nuclei_yaml' must be a non-empty string")
877
+
878
+ LOGGER.info(
879
+ "Starting WAF stack for nuclei test (image=%s, target_url=%s)",
880
+ WAF_DEFAULT_NUCLEI_IMAGE,
881
+ WAF_DEFAULT_TARGET_URL,
882
+ )
883
+
884
+ target_endpoint, stack_error = _start_waf_test_stack(rule_yaml)
885
+ if stack_error:
886
+ if "appears to be running already" in stack_error.lower():
887
+ LOGGER.info("Existing stack detected; attempting restart before running tests")
888
+ _stop_waf_test_stack()
889
+ target_endpoint, stack_error = _start_waf_test_stack(rule_yaml)
890
+ if stack_error:
891
+ LOGGER.error("Unable to start WAF stack: %s", stack_error)
892
+ raise RuntimeError(f"Unable to start WAF stack: {stack_error}")
893
+ stack_started_here = True
894
+
895
+ with tempfile.TemporaryDirectory(prefix="waf-test-") as temp_dir:
896
+ workspace = Path(temp_dir)
897
+
898
+ template_path = workspace / "nuclei-template.yaml"
899
+ template_path.parent.mkdir(parents=True, exist_ok=True)
900
+ template_path.write_text(nuclei_yaml, encoding="utf-8")
901
+
902
+ LOGGER.info(
903
+ "Running nuclei template against %s (image=%s)",
904
+ WAF_DEFAULT_TARGET_URL,
905
+ WAF_DEFAULT_NUCLEI_IMAGE,
906
+ )
907
+ success, message = _run_nuclei_container(
908
+ workspace,
909
+ template_path,
910
+ nuclei_image=WAF_DEFAULT_NUCLEI_IMAGE,
911
+ target_url=WAF_DEFAULT_TARGET_URL,
912
+ )
913
+
914
+ if not success:
915
+ stack_logs = _collect_compose_logs(["crowdsec", "nginx"])
916
+ parts = [
917
+ "❌ Nuclei test failed.",
918
+ "=== NUCLEI OUTPUT ===",
919
+ message,
920
+ ]
921
+ if stack_logs:
922
+ parts.append("=== STACK LOGS (crowdsec/nginx) ===")
923
+ parts.append(stack_logs)
924
+ joined = "\n\n".join(parts)
925
+ raise RuntimeError(joined)
926
+
927
+ success_sections = [
928
+ "✅ Nuclei test succeeded.",
929
+ f"Target endpoint inside the stack: {WAF_DEFAULT_TARGET_URL}",
930
+ f"Host accessible endpoint: {target_endpoint or 'unknown'}",
931
+ "=== NUCLEI OUTPUT ===",
932
+ message,
933
+ ]
934
+ stack_logs = _collect_compose_logs(["crowdsec", "nginx"])
935
+ if stack_logs:
936
+ success_sections.extend(
937
+ [
938
+ "=== STACK LOGS (crowdsec/nginx) ===",
939
+ stack_logs,
940
+ ]
941
+ )
776
942
  return [
777
943
  types.TextContent(
778
944
  type="text",
779
- text=f"❌ Stack management error: {str(exc)}",
945
+ text="\n\n".join(success_sections),
780
946
  )
781
947
  ]
782
948
 
949
+ except Exception as exc:
950
+ LOGGER.error("run_waf_tests error: %s", exc, exc_info=True)
951
+ raise
952
+ finally:
953
+ if stack_started_here:
954
+ try:
955
+ _stop_waf_test_stack()
956
+ except Exception as stop_exc: # pragma: no cover - best effort cleanup
957
+ LOGGER.warning("Failed to stop WAF stack during cleanup: %s", stop_exc)
783
958
 
784
- def _search_repo_for_cve(repo_path: Path, cve: str) -> List[Path]:
959
+ def _search_repo_for_cve(repo_path: Path, cve: str) -> list[Path]:
785
960
  """Return files whose name contains the CVE identifier (case-insensitive)."""
786
961
  lower_token = cve.lower()
787
- matches: List[Path] = []
962
+ matches: list[Path] = []
788
963
 
789
964
  for candidate in repo_path.rglob("*"):
790
965
  if not candidate.is_file():
@@ -795,7 +970,7 @@ def _search_repo_for_cve(repo_path: Path, cve: str) -> List[Path]:
795
970
  return matches
796
971
 
797
972
 
798
- def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
973
+ def _tool_fetch_nuclei_exploit(arguments: dict[str, Any] | None) -> list[types.TextContent]:
799
974
  try:
800
975
  if not arguments:
801
976
  LOGGER.warning("fetch_nuclei_exploit called without arguments")
@@ -814,15 +989,15 @@ def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[type
814
989
  target_path.mkdir(parents=True, exist_ok=True)
815
990
 
816
991
  LOGGER.info("Fetching nuclei exploit templates for %s", cve)
817
- findings: List[str] = []
818
- rendered_templates: List[str] = []
992
+ findings: list[str] = []
993
+ rendered_templates: list[str] = []
819
994
  total_files = 0
820
995
 
821
996
  for repo_url in DEFAULT_EXPLOIT_REPOSITORIES:
822
997
  cleaned_url = repo_url.rstrip("/")
823
998
  repo_name = cleaned_url.split("/")[-1] or "repository"
824
999
  if repo_name.endswith(".git"):
825
- repo_name = repo_name[:-4]
1000
+ repo_name = repo_name.removesuffix(".git")
826
1001
  repo_path = target_path / repo_name
827
1002
 
828
1003
  if repo_path.exists():
@@ -898,16 +1073,11 @@ def _tool_fetch_nuclei_exploit(arguments: Optional[Dict[str, Any]]) -> List[type
898
1073
  ]
899
1074
 
900
1075
  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
- ]
1076
+ LOGGER.error("fetch_nuclei_exploit error: %s", exc, exc_info=True)
1077
+ raise
908
1078
 
909
1079
 
910
- def _tool_curl_waf_endpoint(arguments: Optional[Dict[str, Any]]) -> List[types.TextContent]:
1080
+ def _tool_curl_waf_endpoint(arguments: dict[str, Any] | None) -> list[types.TextContent]:
911
1081
  try:
912
1082
  if not arguments:
913
1083
  LOGGER.warning("curl_waf_endpoint called without arguments")
@@ -975,28 +1145,35 @@ def _tool_curl_waf_endpoint(arguments: Optional[Dict[str, Any]]) -> List[types.T
975
1145
  ]
976
1146
 
977
1147
  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
- ]
1148
+ LOGGER.error("curl_waf_endpoint error: %s", exc, exc_info=True)
1149
+ raise
985
1150
 
986
1151
 
987
- WAF_TOOL_HANDLERS: Dict[str, ToolHandler] = {
1152
+ WAF_TOOL_HANDLERS: dict[str, ToolHandler] = {
1153
+ "get_waf_top_level_prompt": _tool_get_waf_top_level_prompt,
988
1154
  "get_waf_prompt": _tool_get_waf_prompt,
989
1155
  "get_waf_examples": _tool_get_waf_examples,
990
1156
  "generate_waf_rule": _tool_generate_waf_rule,
1157
+ "generate_waf_tests": _tool_generate_waf_tests,
991
1158
  "validate_waf_rule": _tool_validate_waf_rule,
992
1159
  "lint_waf_rule": _tool_lint_waf_rule,
993
1160
  "deploy_waf_rule": _tool_deploy_waf_rule,
994
1161
  "fetch_nuclei_exploit": _tool_fetch_nuclei_exploit,
995
1162
  "manage_waf_stack": _tool_manage_waf_stack,
1163
+ "run_waf_tests": _tool_run_waf_tests,
996
1164
  "curl_waf_endpoint": _tool_curl_waf_endpoint,
997
1165
  }
998
1166
 
999
- WAF_TOOLS: List[types.Tool] = [
1167
+ WAF_TOOLS: list[types.Tool] = [
1168
+ types.Tool(
1169
+ name="get_waf_top_level_prompt",
1170
+ description="Get the top-level CrowdSec WAF workflow prompt that explains how to approach rule and test creation",
1171
+ inputSchema={
1172
+ "type": "object",
1173
+ "properties": {},
1174
+ "additionalProperties": False,
1175
+ },
1176
+ ),
1000
1177
  types.Tool(
1001
1178
  name="get_waf_prompt",
1002
1179
  description="Get the main WAF rule generation prompt for CrowdSec",
@@ -1029,6 +1206,44 @@ WAF_TOOLS: List[types.Tool] = [
1029
1206
  "additionalProperties": False,
1030
1207
  },
1031
1208
  ),
1209
+ types.Tool(
1210
+ name="generate_waf_tests",
1211
+ description="Get the WAF test generation prompt for producing config.yaml and adapted Nuclei templates",
1212
+ inputSchema={
1213
+ "type": "object",
1214
+ "properties": {
1215
+ "nuclei_template": {
1216
+ "type": "string",
1217
+ "description": "Optional Nuclei template to include so the assistant can adapt it for testing",
1218
+ },
1219
+ "rule_filename": {
1220
+ "type": "string",
1221
+ "description": "Optional path to the generated rule (e.g. ./appsec-rules/crowdsecurity/vpatch-CVE-XXXX-YYYY.yaml)",
1222
+ },
1223
+ },
1224
+ "additionalProperties": False,
1225
+ },
1226
+ ),
1227
+ types.Tool(
1228
+ name="run_waf_tests",
1229
+ description="Start the WAF harness and execute the provided nuclei test template against it."
1230
+ " If this action fails because docker isn't present or cannot be run, prompt the user to set it up manually.",
1231
+ inputSchema={
1232
+ "type": "object",
1233
+ "properties": {
1234
+ "rule_yaml": {
1235
+ "type": "string",
1236
+ "description": "CrowdSec WAF rule YAML to load into the harness before running tests",
1237
+ },
1238
+ "nuclei_yaml": {
1239
+ "type": "string",
1240
+ "description": "Adapted nuclei template YAML that should trigger a block (HTTP 403)",
1241
+ },
1242
+ },
1243
+ "required": ["rule_yaml", "nuclei_yaml"],
1244
+ "additionalProperties": False,
1245
+ },
1246
+ ),
1032
1247
  types.Tool(
1033
1248
  name="validate_waf_rule",
1034
1249
  description="Validate that a CrowdSec WAF rule YAML is syntactically correct",
@@ -1085,7 +1300,8 @@ WAF_TOOLS: List[types.Tool] = [
1085
1300
  ),
1086
1301
  types.Tool(
1087
1302
  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",
1303
+ description="Start or stop the Docker-based CrowdSec AppSec test stack so the rule can be exercised with allowed and blocked requests."
1304
+ " If this action fails because docker isn't present or cannot be run, prompt the user to set it up manually.",
1089
1305
  inputSchema={
1090
1306
  "type": "object",
1091
1307
  "properties": {
@@ -1139,7 +1355,13 @@ WAF_TOOLS: List[types.Tool] = [
1139
1355
  ),
1140
1356
  ]
1141
1357
 
1142
- WAF_RESOURCES: List[types.Resource] = [
1358
+ WAF_RESOURCES: list[types.Resource] = [
1359
+ types.Resource(
1360
+ uri="file://prompts/prompt-waf-top-level.txt",
1361
+ name="WAF Top-Level Workflow Prompt",
1362
+ description="High-level guidance for handling CrowdSec WAF rule requests and which tools to use",
1363
+ mimeType="text/plain",
1364
+ ),
1143
1365
  types.Resource(
1144
1366
  uri="file://prompts/prompt-waf.txt",
1145
1367
  name="WAF Rule Generation Prompt",
@@ -1158,12 +1380,20 @@ WAF_RESOURCES: List[types.Resource] = [
1158
1380
  description="Step-by-step guide for deploying CrowdSec WAF rules",
1159
1381
  mimeType="text/plain",
1160
1382
  ),
1383
+ types.Resource(
1384
+ uri="file://prompts/prompt-waf-tests.txt",
1385
+ name="WAF Test Generation Prompt",
1386
+ description="Instructions for producing config.yaml and adapted Nuclei templates for WAF testing",
1387
+ mimeType="text/plain",
1388
+ ),
1161
1389
  ]
1162
1390
 
1163
- WAF_RESOURCE_READERS: Dict[str, Callable[[], str]] = {
1391
+ WAF_RESOURCE_READERS: dict[str, Callable[[], str]] = {
1392
+ "file://prompts/prompt-waf-top-level.txt": lambda: WAF_TOP_LEVEL_PROMPT_FILE.read_text(encoding="utf-8"),
1164
1393
  "file://prompts/prompt-waf.txt": lambda: WAF_PROMPT_FILE.read_text(encoding="utf-8"),
1165
1394
  "file://prompts/prompt-waf-examples.txt": lambda: WAF_EXAMPLES_FILE.read_text(encoding="utf-8"),
1166
1395
  "file://prompts/prompt-waf-deploy.txt": lambda: WAF_DEPLOY_FILE.read_text(encoding="utf-8"),
1396
+ "file://prompts/prompt-waf-tests.txt": lambda: WAF_TESTS_PROMPT_FILE.read_text(encoding="utf-8"),
1167
1397
  }
1168
1398
 
1169
1399
  REGISTRY.register_tools(WAF_TOOL_HANDLERS, WAF_TOOLS)