crowdsec-local-mcp 0.7.0.post1.dev0__tar.gz → 0.8.0.post1.dev0__tar.gz

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 (60) hide show
  1. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/PKG-INFO +1 -1
  2. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/manifest.json +8 -0
  3. crowdsec_local_mcp-0.8.0.post1.dev0/src/crowdsec_local_mcp/_version.py +1 -0
  4. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/nginx/Dockerfile +7 -2
  5. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/mcp_waf.py +262 -120
  6. crowdsec_local_mcp-0.8.0.post1.dev0/src/crowdsec_local_mcp/prompts/prompt-waf-pr.txt +10 -0
  7. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf-tests.txt +12 -0
  8. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf-top-level.txt +3 -1
  9. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/PKG-INFO +1 -1
  10. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/SOURCES.txt +1 -0
  11. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/uv.lock +24 -0
  12. crowdsec_local_mcp-0.7.0.post1.dev0/src/crowdsec_local_mcp/_version.py +0 -1
  13. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.github/workflows/build-mcpb.yml +0 -0
  14. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.github/workflows/build.yml +0 -0
  15. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.github/workflows/lint.yaml +0 -0
  16. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.github/workflows/publish.yml +0 -0
  17. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.gitignore +0 -0
  18. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.mcpbignore +0 -0
  19. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.python-version +0 -0
  20. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/LICENSE +0 -0
  21. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/MANIFEST.in +0 -0
  22. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/README.md +0 -0
  23. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/crowdsec_logo.png +0 -0
  24. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/pyproject.toml +0 -0
  25. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/setup.cfg +0 -0
  26. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/__init__.py +0 -0
  27. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/__main__.py +0 -0
  28. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/scenario-test/.gitignore +0 -0
  29. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/scenario-test/docker-compose.yml +0 -0
  30. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/scenario-test/scenarios/.gitkeep +0 -0
  31. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/.gitignore +0 -0
  32. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/crowdsec/acquis.d/appsec.yaml +0 -0
  33. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/crowdsec/appsec-configs/mcp-appsec.yaml.template +0 -0
  34. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/crowdsec/init-bouncer.sh +0 -0
  35. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/docker-compose.yml +0 -0
  36. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/nginx/crowdsec/crowdsec-openresty-bouncer.conf +0 -0
  37. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/nginx/nginx.conf +0 -0
  38. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/nginx/site-enabled/default-site.conf +0 -0
  39. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/rules/.gitkeep +0 -0
  40. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/rules/base-config.yaml +0 -0
  41. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/mcp_core.py +0 -0
  42. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/mcp_scenarios.py +0 -0
  43. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-expr-helpers.txt +0 -0
  44. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-scenario-deploy.txt +0 -0
  45. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-scenario-examples.txt +0 -0
  46. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-scenario.txt +0 -0
  47. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf-deploy.txt +0 -0
  48. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf-examples.txt +0 -0
  49. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf.txt +0 -0
  50. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/setup_cli.py +0 -0
  51. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/yaml-schemas/appsec_rules_schema.yaml +0 -0
  52. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/yaml-schemas/scenario_schema.yaml +0 -0
  53. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/dependency_links.txt +0 -0
  54. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/entry_points.txt +0 -0
  55. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/requires.txt +0 -0
  56. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/top_level.txt +0 -0
  57. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tests/__init__.py +0 -0
  58. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tests/test_mcp_waf_lint.py +0 -0
  59. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tools/__init__.py +0 -0
  60. {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tools/update_manifest_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdsec-local-mcp
3
- Version: 0.7.0.post1.dev0
3
+ Version: 0.8.0.post1.dev0
4
4
  Summary: An MCP exposing prompts and tools to help users write WAF rules, scenarios etc.
5
5
  License-Expression: MIT
6
6
  Classifier: Development Status :: 4 - Beta
@@ -55,6 +55,10 @@
55
55
  "name": "generate_waf_tests",
56
56
  "description": "Get the WAF test generation prompt for producing config.yaml and adapted Nuclei templates"
57
57
  },
58
+ {
59
+ "name": "get_waf_pr_prompt",
60
+ "description": "Get the WAF PR preparation prompt for writing test assets and drafting a PR comment"
61
+ },
58
62
  {
59
63
  "name": "run_waf_tests",
60
64
  "description": "Start the WAF harness and execute the provided nuclei test template against it"
@@ -71,6 +75,10 @@
71
75
  "name": "deploy_waf_rule",
72
76
  "description": "Get deployment instructions for CrowdSec WAF rules"
73
77
  },
78
+ {
79
+ "name": "prepare_waf_pr",
80
+ "description": "Write the generated WAF rule and AppSec test assets into a local CrowdSec hub clone, and optionally add the rule to the appsec-virtual-patching collection"
81
+ },
74
82
  {
75
83
  "name": "fetch_nuclei_exploit",
76
84
  "description": "Retrieve nuclei templates from the official repository for a CVE to help with generation of WAF rules"
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0.post1.dev0"
@@ -13,8 +13,13 @@ RUN apt-get update && apt-get install -y \
13
13
  curl
14
14
 
15
15
  RUN wget -O - https://openresty.org/package/pubkey.gpg | gpg --dearmor -o /usr/share/keyrings/openresty.gpg
16
- RUN echo "deb [arch=amd64 signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main"| tee /etc/apt/sources.list.d/openresty.list
17
- RUN echo "deb [arch=arm64 signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/arm64/ubuntu $(lsb_release -sc) main"| tee /etc/apt/sources.list.d/openresty.list
16
+ # Use TARGETARCH to select the right repository
17
+ ARG TARGETARCH
18
+ RUN if [ "$TARGETARCH" = "amd64" ]; then \
19
+ echo "deb [arch=amd64 signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/openresty.list; \
20
+ elif [ "$TARGETARCH" = "arm64" ]; then \
21
+ echo "deb [arch=arm64 signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/arm64/ubuntu $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/openresty.list; \
22
+ fi
18
23
  RUN curl -s https://install.crowdsec.net | bash
19
24
 
20
25
  RUN apt update
@@ -28,6 +28,7 @@ WAF_PROMPT_FILE = PROMPTS_DIR / "prompt-waf.txt"
28
28
  WAF_EXAMPLES_FILE = PROMPTS_DIR / "prompt-waf-examples.txt"
29
29
  WAF_DEPLOY_FILE = PROMPTS_DIR / "prompt-waf-deploy.txt"
30
30
  WAF_TESTS_PROMPT_FILE = PROMPTS_DIR / "prompt-waf-tests.txt"
31
+ WAF_PR_PROMPT_FILE = PROMPTS_DIR / "prompt-waf-pr.txt"
31
32
 
32
33
  CROWDSEC_SCHEMAS_DIR = SCRIPT_DIR / "yaml-schemas"
33
34
  WAF_SCHEMA_FILE = CROWDSEC_SCHEMAS_DIR / "appsec_rules_schema.yaml"
@@ -35,18 +36,8 @@ WAF_SCHEMA_FILE = CROWDSEC_SCHEMAS_DIR / "appsec_rules_schema.yaml"
35
36
  WAF_TEST_COMPOSE_DIR = SCRIPT_DIR / "compose" / "waf-test"
36
37
  WAF_TEST_COMPOSE_FILE = WAF_TEST_COMPOSE_DIR / "docker-compose.yml"
37
38
  WAF_TEST_RULE_PATH = WAF_TEST_COMPOSE_DIR / "rules" / "current-rule.yaml"
38
- WAF_TEST_APPSEC_TEMPLATE = (
39
- WAF_TEST_COMPOSE_DIR
40
- / "crowdsec"
41
- / "appsec-configs"
42
- / "mcp-appsec.yaml.template"
43
- )
44
- WAF_TEST_APPSEC_CONFIG = (
45
- WAF_TEST_COMPOSE_DIR
46
- / "crowdsec"
47
- / "appsec-configs"
48
- / "mcp-appsec.yaml"
49
- )
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"
50
41
  WAF_RULE_NAME_PLACEHOLDER = "__PLACEHOLDER_FOR_USER_RULE__"
51
42
  WAF_TEST_PROJECT_NAME = "crowdsec-mcp-waf"
52
43
  WAF_TEST_NETWORK_NAME = f"{WAF_TEST_PROJECT_NAME}_waf-net"
@@ -83,11 +74,7 @@ def _collect_compose_logs(services: list[str] | None = None, tail_lines: int = 2
83
74
  check=False,
84
75
  )
85
76
 
86
- combined = "\n".join(
87
- part.strip()
88
- for part in ((result.stdout or ""), (result.stderr or ""))
89
- if part
90
- ).strip()
77
+ combined = "\n".join(part.strip() for part in ((result.stdout or ""), (result.stderr or "")) if part).strip()
91
78
 
92
79
  if not combined:
93
80
  return ""
@@ -99,9 +86,7 @@ def _collect_compose_logs(services: list[str] | None = None, tail_lines: int = 2
99
86
  return "\n".join(lines)
100
87
 
101
88
 
102
- def _run_compose_command(
103
- args: list[str], capture_output: bool = True, check: bool = True
104
- ) -> subprocess.CompletedProcess:
89
+ def _run_compose_command(args: list[str], capture_output: bool = True, check: bool = True) -> subprocess.CompletedProcess:
105
90
  """Run a docker compose command inside the WAF test harness directory."""
106
91
  base_cmd = ensure_docker_compose_cli()
107
92
  full_cmd = base_cmd + ["-p", WAF_TEST_PROJECT_NAME, "-f", str(WAF_TEST_COMPOSE_FILE)] + args
@@ -117,10 +102,7 @@ def _run_compose_command(
117
102
  )
118
103
  except (FileNotFoundError, PermissionError) as error:
119
104
  LOGGER.error("Compose command failed to start: %s", 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
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
124
106
  except subprocess.CalledProcessError as error:
125
107
  stdout = (error.stdout or "").strip()
126
108
  stderr = (error.stderr or "").strip()
@@ -132,14 +114,10 @@ def _run_compose_command(
132
114
  error.returncode,
133
115
  combined.splitlines()[0] if combined else "no output",
134
116
  )
135
- raise RuntimeError(
136
- f"docker compose {' '.join(args)} failed (exit code {error.returncode}):\n{combined}"
137
- ) from error
117
+ raise RuntimeError(f"docker compose {' '.join(args)} failed (exit code {error.returncode}):\n{combined}") from error
138
118
 
139
119
 
140
- def _run_compose_exec(
141
- args: list[str], capture_output: bool = True, check: bool = True
142
- ) -> subprocess.CompletedProcess:
120
+ def _run_compose_exec(args: list[str], capture_output: bool = True, check: bool = True) -> subprocess.CompletedProcess:
143
121
  """Run docker compose exec against the CrowdSec container."""
144
122
  exec_args = ["exec", "-T"] + args
145
123
  return _run_compose_command(exec_args, capture_output=capture_output, check=check)
@@ -149,9 +127,7 @@ def _teardown_compose_stack(check: bool = True) -> None:
149
127
  """Stop the compose stack and ensure any supervising process is terminated."""
150
128
  global _COMPOSE_STACK_PROCESS
151
129
  if not WAF_TEST_COMPOSE_FILE.exists():
152
- LOGGER.warning(
153
- "Requested stack teardown but compose file %s is missing", WAF_TEST_COMPOSE_FILE
154
- )
130
+ LOGGER.warning("Requested stack teardown but compose file %s is missing", WAF_TEST_COMPOSE_FILE)
155
131
  _COMPOSE_STACK_PROCESS = None
156
132
  return
157
133
 
@@ -163,9 +139,7 @@ def _teardown_compose_stack(check: bool = True) -> None:
163
139
  try:
164
140
  _COMPOSE_STACK_PROCESS.wait(timeout=15)
165
141
  except subprocess.TimeoutExpired:
166
- LOGGER.warning(
167
- "Compose stack process did not exit in time; terminating forcefully"
168
- )
142
+ LOGGER.warning("Compose stack process did not exit in time; terminating forcefully")
169
143
  _COMPOSE_STACK_PROCESS.kill()
170
144
  _COMPOSE_STACK_PROCESS.wait(timeout=5)
171
145
  _COMPOSE_STACK_PROCESS = None
@@ -183,14 +157,9 @@ def _wait_for_crowdsec_ready(timeout: int = 90) -> None:
183
157
  _COMPOSE_STACK_PROCESS = None
184
158
  logs = _collect_compose_logs(["crowdsec", "nginx", "backend"])
185
159
  log_section = f"\n\nService logs:\n{logs}" if logs else ""
186
- raise RuntimeError(
187
- "WAF stack exited while waiting for CrowdSec to become ready"
188
- f" (exit code {exit_code}).{log_section}"
189
- )
160
+ raise RuntimeError(f"WAF stack exited while waiting for CrowdSec to become ready (exit code {exit_code}).{log_section}")
190
161
  try:
191
- result = _run_compose_exec(
192
- ["crowdsec", "cscli", "lapi", "status"], capture_output=True, check=False
193
- )
162
+ result = _run_compose_exec(["crowdsec", "cscli", "lapi", "status"], capture_output=True, check=False)
194
163
  if isinstance(result, subprocess.CompletedProcess) and result.returncode == 0:
195
164
  LOGGER.info("CrowdSec API is ready")
196
165
  return
@@ -264,10 +233,7 @@ def _run_nuclei_container(
264
233
 
265
234
  if result.returncode != 0:
266
235
  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
- )
236
+ failure = f"Nuclei container exited with status {result.returncode}." + (f"\n\n{detail_text}" if detail_text else "")
271
237
  return (False, failure)
272
238
 
273
239
  matches: list[dict[str, Any]] = []
@@ -290,10 +256,7 @@ def _run_nuclei_container(
290
256
  if unmatched_lines:
291
257
  info_lines.append("Nuclei produced output but no matches were recorded:\n" + "\n".join(unmatched_lines))
292
258
  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
- )
259
+ info_lines.append("Nuclei completed successfully but reported zero matches. The WAF rule likely did not block the request (missing HTTP 403).")
297
260
  if stderr:
298
261
  info_lines.append(f"stderr:\n{stderr}")
299
262
  return (False, "\n\n".join(info_lines))
@@ -306,9 +269,7 @@ def _run_nuclei_container(
306
269
  url = match.get("matched-at") or match.get("matchedAt") or target_url
307
270
  summary_lines.append(f" - {template_id} matched at {url}")
308
271
  if unmatched_lines:
309
- summary_lines.append(
310
- "Additional nuclei output:\n" + "\n".join(unmatched_lines)
311
- )
272
+ summary_lines.append("Additional nuclei output:\n" + "\n".join(unmatched_lines))
312
273
  if stderr:
313
274
  summary_lines.append(f"stderr:\n{stderr}")
314
275
  return (True, "\n".join(summary_lines))
@@ -479,9 +440,7 @@ def _validate_waf_rule(rule_yaml: str) -> list[types.TextContent]:
479
440
  ]
480
441
 
481
442
 
482
- def _analyze_rule_item(
483
- rule_item: Any, rule_path: str, warnings: list[str]
484
- ) -> tuple[bool, bool]:
443
+ def _analyze_rule_item(rule_item: Any, rule_path: str, warnings: list[str]) -> tuple[bool, bool]:
485
444
  """Recursively inspect rule items, track operator usage, and record warnings."""
486
445
  if not isinstance(rule_item, dict):
487
446
  return (False, False)
@@ -493,9 +452,7 @@ def _analyze_rule_item(
493
452
  contains_or = has_or
494
453
 
495
454
  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
- )
455
+ warnings.append(f"{location} mixes 'and' and 'or' operators at the same level; split them into separate nested blocks")
499
456
 
500
457
  if has_and:
501
458
  for i, sub_rule in enumerate(rule_item["and"]):
@@ -523,20 +480,13 @@ def _analyze_rule_item(
523
480
  match_type = match.get("type", "")
524
481
  match_value = match.get("value", "")
525
482
 
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
- ):
483
+ if match_type in CASE_SENSITIVE_MATCH_TYPES and isinstance(match_value, str) and any(c.isupper() for c in match_value):
531
484
  transforms = rule_item.get("transform", [])
532
- has_lowercase = (
533
- "lowercase" in transforms if isinstance(transforms, list) else False
534
- )
485
+ has_lowercase = "lowercase" in transforms if isinstance(transforms, list) else False
535
486
 
536
487
  if not has_lowercase:
537
488
  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"
489
+ f"Match at {location} uses '{match_type}' with uppercase letters but no 'lowercase' transform - consider adding lowercase transform for case-insensitive matching"
540
490
  )
541
491
 
542
492
  if isinstance(match_value, str):
@@ -544,9 +494,7 @@ def _analyze_rule_item(
544
494
  sql_keywords = [kw for kw in SQL_KEYWORD_INDICATORS if kw in lower_value]
545
495
  if sql_keywords:
546
496
  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
- )
497
+ warnings.append(f"Match at {location} contains SQL keyword(s) ({keywords_str}); instead of keyword blacklisting, detect escaping characters like quotes or semicolons")
550
498
 
551
499
  transforms = rule_item.get("transform", [])
552
500
  if isinstance(transforms, list) and "urldecode" in transforms:
@@ -590,9 +538,7 @@ def lint_waf_rule(rule_yaml: str) -> list[types.TextContent]:
590
538
  name = parsed.get("name", "")
591
539
  if isinstance(name, str):
592
540
  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
- )
541
+ warnings.append("Rule name starts with 'crowdsecurity/' which is reserved for official CrowdSec rules; consider using your own namespace")
596
542
  else:
597
543
  warnings.append("Field 'name' should be a string")
598
544
 
@@ -600,9 +546,7 @@ def lint_waf_rule(rule_yaml: str) -> list[types.TextContent]:
600
546
  for i, rule in enumerate(parsed["rules"]):
601
547
  rule_has_and, rule_has_or = _analyze_rule_item(rule, f"[{i}]", warnings)
602
548
  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
- )
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")
606
550
 
607
551
  result_lines: list[str] = []
608
552
 
@@ -644,9 +588,7 @@ def _tool_get_waf_top_level_prompt(_: dict[str, Any] | None) -> list[types.TextC
644
588
  ]
645
589
  except FileNotFoundError as exc:
646
590
  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
591
+ raise FileNotFoundError(f"WAF top-level prompt file not found at {WAF_TOP_LEVEL_PROMPT_FILE}") from exc
650
592
  except Exception as exc:
651
593
  LOGGER.error("Error loading WAF top-level prompt: %s", exc)
652
594
  raise RuntimeError(f"Error reading WAF top-level prompt file: {exc!s}") from exc
@@ -701,10 +643,7 @@ def _tool_generate_waf_rule(arguments: dict[str, Any] | None) -> list[types.Text
701
643
  bool(nuclei_template),
702
644
  )
703
645
  if nuclei_template:
704
- combined_prompt += (
705
- "\n\n### Input Nuclei Template to Process:\n"
706
- f"```yaml\n{nuclei_template}\n```"
707
- )
646
+ combined_prompt += f"\n\n### Input Nuclei Template to Process:\n```yaml\n{nuclei_template}\n```"
708
647
 
709
648
  return [
710
649
  types.TextContent(
@@ -735,17 +674,10 @@ def _tool_generate_waf_tests(arguments: dict[str, Any] | None) -> list[types.Tex
735
674
  combined_prompt = tests_prompt
736
675
 
737
676
  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
- )
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."
743
678
 
744
679
  if nuclei_template:
745
- combined_prompt += (
746
- "\n\n### Input Nuclei Template to Adapt:\n"
747
- f"```yaml\n{nuclei_template}\n```"
748
- )
680
+ combined_prompt += f"\n\n### Input Nuclei Template to Adapt:\n```yaml\n{nuclei_template}\n```"
749
681
 
750
682
  return [
751
683
  types.TextContent(
@@ -761,6 +693,19 @@ def _tool_generate_waf_tests(arguments: dict[str, Any] | None) -> list[types.Tex
761
693
  raise RuntimeError(f"Error generating WAF test prompt: {exc!s}") from exc
762
694
 
763
695
 
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
+
764
709
  def _tool_validate_waf_rule(arguments: dict[str, Any] | None) -> list[types.TextContent]:
765
710
  if not arguments or "rule_yaml" not in arguments:
766
711
  LOGGER.warning("Validation request missing 'rule_yaml' argument")
@@ -805,6 +750,156 @@ def _tool_deploy_waf_rule(_: dict[str, Any] | None) -> list[types.TextContent]:
805
750
  raise RuntimeError(f"Error reading WAF deployment guide: {exc!s}") from exc
806
751
 
807
752
 
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
+
808
903
  def _tool_manage_waf_stack(arguments: dict[str, Any] | None) -> list[types.TextContent]:
809
904
  try:
810
905
  if not arguments:
@@ -830,9 +925,7 @@ def _tool_manage_waf_stack(arguments: dict[str, Any] | None) -> list[types.TextC
830
925
 
831
926
  if not target_url:
832
927
  LOGGER.error("WAF stack start returned no target URL and no explicit error")
833
- raise RuntimeError(
834
- "WAF stack start error: stack did not return a service URL and reported no specific error."
835
- )
928
+ raise RuntimeError("WAF stack start error: stack did not return a service URL and reported no specific error.")
836
929
 
837
930
  return [
838
931
  types.TextContent(
@@ -956,6 +1049,7 @@ def _tool_run_waf_tests(arguments: dict[str, Any] | None) -> list[types.TextCont
956
1049
  except Exception as stop_exc: # pragma: no cover - best effort cleanup
957
1050
  LOGGER.warning("Failed to stop WAF stack during cleanup: %s", stop_exc)
958
1051
 
1052
+
959
1053
  def _search_repo_for_cve(repo_path: Path, cve: str) -> list[Path]:
960
1054
  """Return files whose name contains the CVE identifier (case-insensitive)."""
961
1055
  lower_token = cve.lower()
@@ -1002,9 +1096,7 @@ def _tool_fetch_nuclei_exploit(arguments: dict[str, Any] | None) -> list[types.T
1002
1096
 
1003
1097
  if repo_path.exists():
1004
1098
  if not (repo_path / ".git").exists():
1005
- raise RuntimeError(
1006
- f"Destination {repo_path} exists but is not a git repository"
1007
- )
1099
+ raise RuntimeError(f"Destination {repo_path} exists but is not a git repository")
1008
1100
  git_cmd = ["git", "-C", str(repo_path), "pull", "--ff-only"]
1009
1101
  else:
1010
1102
  git_cmd = ["git", "clone", "--depth", "1", cleaned_url, str(repo_path)]
@@ -1038,9 +1130,7 @@ def _tool_fetch_nuclei_exploit(arguments: dict[str, Any] | None) -> list[types.T
1038
1130
  except OSError as read_err:
1039
1131
  findings.append(f" (failed to read {relative_path}: {read_err})")
1040
1132
  continue
1041
- rendered_templates.append(
1042
- f"### {cleaned_url} :: {relative_path}\n```yaml\n{file_contents}\n```"
1043
- )
1133
+ rendered_templates.append(f"### {cleaned_url} :: {relative_path}\n```yaml\n{file_contents}\n```")
1044
1134
  total_files += 1
1045
1135
 
1046
1136
  if total_files == 0:
@@ -1049,10 +1139,7 @@ def _tool_fetch_nuclei_exploit(arguments: dict[str, Any] | None) -> list[types.T
1049
1139
  return [
1050
1140
  types.TextContent(
1051
1141
  type="text",
1052
- text=(
1053
- f"No files containing {cve} were found in the provided repositories."
1054
- f"{detail_section}"
1055
- ),
1142
+ text=(f"No files containing {cve} were found in the provided repositories.{detail_section}"),
1056
1143
  )
1057
1144
  ]
1058
1145
 
@@ -1099,9 +1186,7 @@ def _tool_curl_waf_endpoint(arguments: dict[str, Any] | None) -> list[types.Text
1099
1186
  if not path.startswith("/"):
1100
1187
  if "://" in path:
1101
1188
  parsed = urllib.parse.urlparse(path)
1102
- path = urllib.parse.urlunparse(
1103
- ("", "", parsed.path or "/", parsed.params, parsed.query, parsed.fragment)
1104
- )
1189
+ path = urllib.parse.urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, parsed.fragment))
1105
1190
  else:
1106
1191
  path = "/" + path
1107
1192
 
@@ -1109,9 +1194,7 @@ def _tool_curl_waf_endpoint(arguments: dict[str, Any] | None) -> list[types.Text
1109
1194
  LOGGER.warning("curl_waf_endpoint received non-string body payload")
1110
1195
  raise ValueError("'body' must be a string when provided")
1111
1196
 
1112
- LOGGER.info(
1113
- "curl_waf_endpoint executing %s request to %s (timeout=%s)", method, path, timeout
1114
- )
1197
+ LOGGER.info("curl_waf_endpoint executing %s request to %s (timeout=%s)", method, path, timeout)
1115
1198
  try:
1116
1199
  response = requests.request(
1117
1200
  method=method,
@@ -1124,12 +1207,7 @@ def _tool_curl_waf_endpoint(arguments: dict[str, Any] | None) -> list[types.Text
1124
1207
  raise RuntimeError(f"HTTP request failed: {req_err}") from req_err
1125
1208
 
1126
1209
  header_lines = "\n".join(f"{k}: {v}" for k, v in response.headers.items())
1127
- response_text = (
1128
- f">>> {method} http://localhost:8081{path}\n"
1129
- f"Status: {response.status_code}\n"
1130
- f"Headers:\n{header_lines}\n\n"
1131
- f"Body:\n{response.text}"
1132
- )
1210
+ response_text = f">>> {method} http://localhost:8081{path}\nStatus: {response.status_code}\nHeaders:\n{header_lines}\n\nBody:\n{response.text}"
1133
1211
 
1134
1212
  LOGGER.info(
1135
1213
  "curl_waf_endpoint completed with status %s for %s %s",
@@ -1154,10 +1232,12 @@ WAF_TOOL_HANDLERS: dict[str, ToolHandler] = {
1154
1232
  "get_waf_prompt": _tool_get_waf_prompt,
1155
1233
  "get_waf_examples": _tool_get_waf_examples,
1156
1234
  "generate_waf_rule": _tool_generate_waf_rule,
1235
+ "get_waf_pr_prompt": _tool_get_waf_pr_prompt,
1157
1236
  "generate_waf_tests": _tool_generate_waf_tests,
1158
1237
  "validate_waf_rule": _tool_validate_waf_rule,
1159
1238
  "lint_waf_rule": _tool_lint_waf_rule,
1160
1239
  "deploy_waf_rule": _tool_deploy_waf_rule,
1240
+ "prepare_waf_pr": _tool_prepare_waf_pr,
1161
1241
  "fetch_nuclei_exploit": _tool_fetch_nuclei_exploit,
1162
1242
  "manage_waf_stack": _tool_manage_waf_stack,
1163
1243
  "run_waf_tests": _tool_run_waf_tests,
@@ -1224,6 +1304,15 @@ WAF_TOOLS: list[types.Tool] = [
1224
1304
  "additionalProperties": False,
1225
1305
  },
1226
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
+ ),
1227
1316
  types.Tool(
1228
1317
  name="run_waf_tests",
1229
1318
  description="Start the WAF harness and execute the provided nuclei test template against it."
@@ -1283,6 +1372,52 @@ WAF_TOOLS: list[types.Tool] = [
1283
1372
  "additionalProperties": False,
1284
1373
  },
1285
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
+ ),
1286
1421
  types.Tool(
1287
1422
  name="fetch_nuclei_exploit",
1288
1423
  description="Retrieve nuclei templates from the official repository for a CVE to help with generation of WAF rules",
@@ -1386,6 +1521,12 @@ WAF_RESOURCES: list[types.Resource] = [
1386
1521
  description="Instructions for producing config.yaml and adapted Nuclei templates for WAF testing",
1387
1522
  mimeType="text/plain",
1388
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
+ ),
1389
1530
  ]
1390
1531
 
1391
1532
  WAF_RESOURCE_READERS: dict[str, Callable[[], str]] = {
@@ -1394,6 +1535,7 @@ WAF_RESOURCE_READERS: dict[str, Callable[[], str]] = {
1394
1535
  "file://prompts/prompt-waf-examples.txt": lambda: WAF_EXAMPLES_FILE.read_text(encoding="utf-8"),
1395
1536
  "file://prompts/prompt-waf-deploy.txt": lambda: WAF_DEPLOY_FILE.read_text(encoding="utf-8"),
1396
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"),
1397
1539
  }
1398
1540
 
1399
1541
  REGISTRY.register_tools(WAF_TOOL_HANDLERS, WAF_TOOLS)
@@ -0,0 +1,10 @@
1
+ You are preparing a CrowdSec WAF pull request in a local hub clone.
2
+
3
+ Use these instructions as the authoritative spec for the test assets you must add. Create the required files under `.appsec-tests/<test_name>/` and ensure `config.yaml` references the exact rule path that was generated.
4
+
5
+ When the files are ready, draft a short PR comment the user can paste. The comment should include:
6
+ - A one-line summary of the rule and tests added.
7
+ - A list of files added or updated.
8
+ - How to run or validate the tests (short, practical guidance).
9
+
10
+ Return only the PR comment text, ready to paste, with no extra explanations.
@@ -8,6 +8,14 @@ You are an expert in generating automated test assets for the CrowdSec WAF harne
8
8
  - `./appsec-rules/crowdsecurity/base-config.yaml`
9
9
  - The path to the generated rule (e.g. `./appsec-rules/crowdsecurity/vpatch-CVE-2020-17496.yaml`).
10
10
  - `nuclei_template` is the filename of the adapted Nuclei template (e.g. `CVE-2020-17496.yaml`).
11
+ - The path in `appsec-rules` is relative to the hub root, because the rule is not on the hub yet.
12
+ - Use this template and replace the placeholders only:
13
+ ```
14
+ appsec-rules:
15
+ - ./appsec-rules/crowdsecurity/base-config.yaml
16
+ - <PUT_THE_NEW_RULE_PATH_HERE>
17
+ nuclei_template: <PUT_THE_TEST_TEMPLATE_FILENAME_HERE>
18
+ ```
11
19
 
12
20
  2. **Adapted Nuclei Template**
13
21
  - Preserve the original request structure that exercises the vulnerability.
@@ -27,6 +35,10 @@ You are an expert in generating automated test assets for the CrowdSec WAF harne
27
35
  - Respect YAML indentation (two spaces) and quote strings that contain special characters.
28
36
  - Mirror the case and structure of URIs, headers, and payloads from the input template.
29
37
  - Provide helpful inline comments only when strictly necessary to explain intentional failures.
38
+ - Place test assets in the hub under `.appsec-tests/<test_name>/`:
39
+ - `config.yaml`
40
+ - `<test_name>.yaml` (the adapted nuclei template)
41
+ - If the rule is for virtual patching, use `CVE-YYYY-XYZ` as the test name and `vpatch-CVE-YYYY-XYZ` as the rule name.
30
42
 
31
43
  ## Output Format
32
44
 
@@ -26,6 +26,8 @@ Once you have successfully generated and validated a WAF rule, propose the user
26
26
  - Create and validate a test for the rule - allows contribution to the hub:
27
27
  - Use tool `generate_waf_tests` to get instructions on test creation
28
28
  - Use tool `run_waf_tests` to run and validate the tests
29
- - Once done, you can suggest the user to open a PR to https://github.com/crowdsecurity/hub/
29
+ - Once done, suggest help preparing a PR
30
+ - Use tool `get_waf_pr_prompt` to guide PR preparation and draft a PR comment
31
+ - Use tool `prepare_waf_pr` to write the rule and test assets into a local hub clone
30
32
  - Help the user to deploy the rule on his setup:
31
33
  - Use tool `deploy_waf_rule` to get detailed instructions and guide the user.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdsec-local-mcp
3
- Version: 0.7.0.post1.dev0
3
+ Version: 0.8.0.post1.dev0
4
4
  Summary: An MCP exposing prompts and tools to help users write WAF rules, scenarios etc.
5
5
  License-Expression: MIT
6
6
  Classifier: Development Status :: 4 - Beta
@@ -45,6 +45,7 @@ src/crowdsec_local_mcp/prompts/prompt-scenario-examples.txt
45
45
  src/crowdsec_local_mcp/prompts/prompt-scenario.txt
46
46
  src/crowdsec_local_mcp/prompts/prompt-waf-deploy.txt
47
47
  src/crowdsec_local_mcp/prompts/prompt-waf-examples.txt
48
+ src/crowdsec_local_mcp/prompts/prompt-waf-pr.txt
48
49
  src/crowdsec_local_mcp/prompts/prompt-waf-tests.txt
49
50
  src/crowdsec_local_mcp/prompts/prompt-waf-top-level.txt
50
51
  src/crowdsec_local_mcp/prompts/prompt-waf.txt
@@ -147,6 +147,7 @@ dev = [
147
147
  { name = "basedpyright" },
148
148
  { name = "pytest" },
149
149
  { name = "ruff" },
150
+ { name = "setuptools-scm" },
150
151
  { name = "types-pyyaml" },
151
152
  { name = "types-requests" },
152
153
  ]
@@ -164,6 +165,7 @@ dev = [
164
165
  { name = "basedpyright", specifier = ">=1.25.0" },
165
166
  { name = "pytest", specifier = ">=8.3.0" },
166
167
  { name = "ruff", specifier = ">=0.9.3" },
168
+ { name = "setuptools-scm", specifier = ">=8" },
167
169
  { name = "types-pyyaml", specifier = ">=6.0.12.20241230" },
168
170
  { name = "types-requests", specifier = ">=2.32.0.20241016" },
169
171
  ]
@@ -627,6 +629,28 @@ wheels = [
627
629
  { url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636 },
628
630
  ]
629
631
 
632
+ [[package]]
633
+ name = "setuptools"
634
+ version = "80.9.0"
635
+ source = { registry = "https://pypi.org/simple" }
636
+ sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 }
637
+ wheels = [
638
+ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 },
639
+ ]
640
+
641
+ [[package]]
642
+ name = "setuptools-scm"
643
+ version = "9.2.2"
644
+ source = { registry = "https://pypi.org/simple" }
645
+ dependencies = [
646
+ { name = "packaging" },
647
+ { name = "setuptools" },
648
+ ]
649
+ sdist = { url = "https://files.pythonhosted.org/packages/7b/b1/19587742aad604f1988a8a362e660e8c3ac03adccdb71c96d86526e5eb62/setuptools_scm-9.2.2.tar.gz", hash = "sha256:1c674ab4665686a0887d7e24c03ab25f24201c213e82ea689d2f3e169ef7ef57", size = 203385 }
650
+ wheels = [
651
+ { url = "https://files.pythonhosted.org/packages/3d/ea/ac2bf868899d0d2e82ef72d350d97a846110c709bacf2d968431576ca915/setuptools_scm-9.2.2-py3-none-any.whl", hash = "sha256:30e8f84d2ab1ba7cb0e653429b179395d0c33775d54807fc5f1dd6671801aef7", size = 62975 },
652
+ ]
653
+
630
654
  [[package]]
631
655
  name = "sniffio"
632
656
  version = "1.3.1"
@@ -1 +0,0 @@
1
- __version__ = "0.7.0.post1.dev0"