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