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