crowdsec-local-mcp 0.7.0.post1.dev0__tar.gz → 0.8.0.post1.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/PKG-INFO +1 -1
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/manifest.json +8 -0
- crowdsec_local_mcp-0.8.0.post1.dev0/src/crowdsec_local_mcp/_version.py +1 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/nginx/Dockerfile +7 -2
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/mcp_waf.py +262 -120
- crowdsec_local_mcp-0.8.0.post1.dev0/src/crowdsec_local_mcp/prompts/prompt-waf-pr.txt +10 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf-tests.txt +12 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf-top-level.txt +3 -1
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/PKG-INFO +1 -1
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/SOURCES.txt +1 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/uv.lock +24 -0
- crowdsec_local_mcp-0.7.0.post1.dev0/src/crowdsec_local_mcp/_version.py +0 -1
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.github/workflows/build-mcpb.yml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.github/workflows/build.yml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.github/workflows/lint.yaml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.github/workflows/publish.yml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.gitignore +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.mcpbignore +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/.python-version +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/LICENSE +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/MANIFEST.in +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/README.md +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/crowdsec_logo.png +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/pyproject.toml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/setup.cfg +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/__init__.py +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/__main__.py +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/scenario-test/.gitignore +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/scenario-test/docker-compose.yml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/scenario-test/scenarios/.gitkeep +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/.gitignore +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/crowdsec/acquis.d/appsec.yaml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/crowdsec/appsec-configs/mcp-appsec.yaml.template +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/crowdsec/init-bouncer.sh +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/docker-compose.yml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/nginx/crowdsec/crowdsec-openresty-bouncer.conf +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/nginx/nginx.conf +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/nginx/site-enabled/default-site.conf +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/rules/.gitkeep +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/compose/waf-test/rules/base-config.yaml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/mcp_core.py +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/mcp_scenarios.py +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-expr-helpers.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-scenario-deploy.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-scenario-examples.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-scenario.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf-deploy.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf-examples.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/prompts/prompt-waf.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/setup_cli.py +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/yaml-schemas/appsec_rules_schema.yaml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp/yaml-schemas/scenario_schema.yaml +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/dependency_links.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/entry_points.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/requires.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/src/crowdsec_local_mcp.egg-info/top_level.txt +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tests/__init__.py +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tests/test_mcp_waf_lint.py +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tools/__init__.py +0 -0
- {crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tools/update_manifest_version.py +0 -0
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"name": "generate_waf_tests",
|
|
56
56
|
"description": "Get the WAF test generation prompt for producing config.yaml and adapted Nuclei templates"
|
|
57
57
|
},
|
|
58
|
+
{
|
|
59
|
+
"name": "get_waf_pr_prompt",
|
|
60
|
+
"description": "Get the WAF PR preparation prompt for writing test assets and drafting a PR comment"
|
|
61
|
+
},
|
|
58
62
|
{
|
|
59
63
|
"name": "run_waf_tests",
|
|
60
64
|
"description": "Start the WAF harness and execute the provided nuclei test template against it"
|
|
@@ -71,6 +75,10 @@
|
|
|
71
75
|
"name": "deploy_waf_rule",
|
|
72
76
|
"description": "Get deployment instructions for CrowdSec WAF rules"
|
|
73
77
|
},
|
|
78
|
+
{
|
|
79
|
+
"name": "prepare_waf_pr",
|
|
80
|
+
"description": "Write the generated WAF rule and AppSec test assets into a local CrowdSec hub clone, and optionally add the rule to the appsec-virtual-patching collection"
|
|
81
|
+
},
|
|
74
82
|
{
|
|
75
83
|
"name": "fetch_nuclei_exploit",
|
|
76
84
|
"description": "Retrieve nuclei templates from the official repository for a CVE to help with generation of WAF rules"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.0.post1.dev0"
|
|
@@ -13,8 +13,13 @@ RUN apt-get update && apt-get install -y \
|
|
|
13
13
|
curl
|
|
14
14
|
|
|
15
15
|
RUN wget -O - https://openresty.org/package/pubkey.gpg | gpg --dearmor -o /usr/share/keyrings/openresty.gpg
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# Use TARGETARCH to select the right repository
|
|
17
|
+
ARG TARGETARCH
|
|
18
|
+
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
|
19
|
+
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/openresty.list; \
|
|
20
|
+
elif [ "$TARGETARCH" = "arm64" ]; then \
|
|
21
|
+
echo "deb [arch=arm64 signed-by=/usr/share/keyrings/openresty.gpg] http://openresty.org/package/arm64/ubuntu $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/openresty.list; \
|
|
22
|
+
fi
|
|
18
23
|
RUN curl -s https://install.crowdsec.net | bash
|
|
19
24
|
|
|
20
25
|
RUN apt update
|
|
@@ -28,6 +28,7 @@ WAF_PROMPT_FILE = PROMPTS_DIR / "prompt-waf.txt"
|
|
|
28
28
|
WAF_EXAMPLES_FILE = PROMPTS_DIR / "prompt-waf-examples.txt"
|
|
29
29
|
WAF_DEPLOY_FILE = PROMPTS_DIR / "prompt-waf-deploy.txt"
|
|
30
30
|
WAF_TESTS_PROMPT_FILE = PROMPTS_DIR / "prompt-waf-tests.txt"
|
|
31
|
+
WAF_PR_PROMPT_FILE = PROMPTS_DIR / "prompt-waf-pr.txt"
|
|
31
32
|
|
|
32
33
|
CROWDSEC_SCHEMAS_DIR = SCRIPT_DIR / "yaml-schemas"
|
|
33
34
|
WAF_SCHEMA_FILE = CROWDSEC_SCHEMAS_DIR / "appsec_rules_schema.yaml"
|
|
@@ -35,18 +36,8 @@ WAF_SCHEMA_FILE = CROWDSEC_SCHEMAS_DIR / "appsec_rules_schema.yaml"
|
|
|
35
36
|
WAF_TEST_COMPOSE_DIR = SCRIPT_DIR / "compose" / "waf-test"
|
|
36
37
|
WAF_TEST_COMPOSE_FILE = WAF_TEST_COMPOSE_DIR / "docker-compose.yml"
|
|
37
38
|
WAF_TEST_RULE_PATH = WAF_TEST_COMPOSE_DIR / "rules" / "current-rule.yaml"
|
|
38
|
-
WAF_TEST_APPSEC_TEMPLATE =
|
|
39
|
-
|
|
40
|
-
/ "crowdsec"
|
|
41
|
-
/ "appsec-configs"
|
|
42
|
-
/ "mcp-appsec.yaml.template"
|
|
43
|
-
)
|
|
44
|
-
WAF_TEST_APPSEC_CONFIG = (
|
|
45
|
-
WAF_TEST_COMPOSE_DIR
|
|
46
|
-
/ "crowdsec"
|
|
47
|
-
/ "appsec-configs"
|
|
48
|
-
/ "mcp-appsec.yaml"
|
|
49
|
-
)
|
|
39
|
+
WAF_TEST_APPSEC_TEMPLATE = WAF_TEST_COMPOSE_DIR / "crowdsec" / "appsec-configs" / "mcp-appsec.yaml.template"
|
|
40
|
+
WAF_TEST_APPSEC_CONFIG = WAF_TEST_COMPOSE_DIR / "crowdsec" / "appsec-configs" / "mcp-appsec.yaml"
|
|
50
41
|
WAF_RULE_NAME_PLACEHOLDER = "__PLACEHOLDER_FOR_USER_RULE__"
|
|
51
42
|
WAF_TEST_PROJECT_NAME = "crowdsec-mcp-waf"
|
|
52
43
|
WAF_TEST_NETWORK_NAME = f"{WAF_TEST_PROJECT_NAME}_waf-net"
|
|
@@ -83,11 +74,7 @@ def _collect_compose_logs(services: list[str] | None = None, tail_lines: int = 2
|
|
|
83
74
|
check=False,
|
|
84
75
|
)
|
|
85
76
|
|
|
86
|
-
combined = "\n".join(
|
|
87
|
-
part.strip()
|
|
88
|
-
for part in ((result.stdout or ""), (result.stderr or ""))
|
|
89
|
-
if part
|
|
90
|
-
).strip()
|
|
77
|
+
combined = "\n".join(part.strip() for part in ((result.stdout or ""), (result.stderr or "")) if part).strip()
|
|
91
78
|
|
|
92
79
|
if not combined:
|
|
93
80
|
return ""
|
|
@@ -99,9 +86,7 @@ def _collect_compose_logs(services: list[str] | None = None, tail_lines: int = 2
|
|
|
99
86
|
return "\n".join(lines)
|
|
100
87
|
|
|
101
88
|
|
|
102
|
-
def _run_compose_command(
|
|
103
|
-
args: list[str], capture_output: bool = True, check: bool = True
|
|
104
|
-
) -> subprocess.CompletedProcess:
|
|
89
|
+
def _run_compose_command(args: list[str], capture_output: bool = True, check: bool = True) -> subprocess.CompletedProcess:
|
|
105
90
|
"""Run a docker compose command inside the WAF test harness directory."""
|
|
106
91
|
base_cmd = ensure_docker_compose_cli()
|
|
107
92
|
full_cmd = base_cmd + ["-p", WAF_TEST_PROJECT_NAME, "-f", str(WAF_TEST_COMPOSE_FILE)] + args
|
|
@@ -117,10 +102,7 @@ def _run_compose_command(
|
|
|
117
102
|
)
|
|
118
103
|
except (FileNotFoundError, PermissionError) as error:
|
|
119
104
|
LOGGER.error("Compose command failed to start: %s", error)
|
|
120
|
-
raise RuntimeError(
|
|
121
|
-
"Docker Compose is required but could not be executed. "
|
|
122
|
-
"Install Docker and ensure the current user can run `docker compose` commands."
|
|
123
|
-
) from error
|
|
105
|
+
raise RuntimeError("Docker Compose is required but could not be executed. Install Docker and ensure the current user can run `docker compose` commands.") from error
|
|
124
106
|
except subprocess.CalledProcessError as error:
|
|
125
107
|
stdout = (error.stdout or "").strip()
|
|
126
108
|
stderr = (error.stderr or "").strip()
|
|
@@ -132,14 +114,10 @@ def _run_compose_command(
|
|
|
132
114
|
error.returncode,
|
|
133
115
|
combined.splitlines()[0] if combined else "no output",
|
|
134
116
|
)
|
|
135
|
-
raise RuntimeError(
|
|
136
|
-
f"docker compose {' '.join(args)} failed (exit code {error.returncode}):\n{combined}"
|
|
137
|
-
) from error
|
|
117
|
+
raise RuntimeError(f"docker compose {' '.join(args)} failed (exit code {error.returncode}):\n{combined}") from error
|
|
138
118
|
|
|
139
119
|
|
|
140
|
-
def _run_compose_exec(
|
|
141
|
-
args: list[str], capture_output: bool = True, check: bool = True
|
|
142
|
-
) -> subprocess.CompletedProcess:
|
|
120
|
+
def _run_compose_exec(args: list[str], capture_output: bool = True, check: bool = True) -> subprocess.CompletedProcess:
|
|
143
121
|
"""Run docker compose exec against the CrowdSec container."""
|
|
144
122
|
exec_args = ["exec", "-T"] + args
|
|
145
123
|
return _run_compose_command(exec_args, capture_output=capture_output, check=check)
|
|
@@ -149,9 +127,7 @@ def _teardown_compose_stack(check: bool = True) -> None:
|
|
|
149
127
|
"""Stop the compose stack and ensure any supervising process is terminated."""
|
|
150
128
|
global _COMPOSE_STACK_PROCESS
|
|
151
129
|
if not WAF_TEST_COMPOSE_FILE.exists():
|
|
152
|
-
LOGGER.warning(
|
|
153
|
-
"Requested stack teardown but compose file %s is missing", WAF_TEST_COMPOSE_FILE
|
|
154
|
-
)
|
|
130
|
+
LOGGER.warning("Requested stack teardown but compose file %s is missing", WAF_TEST_COMPOSE_FILE)
|
|
155
131
|
_COMPOSE_STACK_PROCESS = None
|
|
156
132
|
return
|
|
157
133
|
|
|
@@ -163,9 +139,7 @@ def _teardown_compose_stack(check: bool = True) -> None:
|
|
|
163
139
|
try:
|
|
164
140
|
_COMPOSE_STACK_PROCESS.wait(timeout=15)
|
|
165
141
|
except subprocess.TimeoutExpired:
|
|
166
|
-
LOGGER.warning(
|
|
167
|
-
"Compose stack process did not exit in time; terminating forcefully"
|
|
168
|
-
)
|
|
142
|
+
LOGGER.warning("Compose stack process did not exit in time; terminating forcefully")
|
|
169
143
|
_COMPOSE_STACK_PROCESS.kill()
|
|
170
144
|
_COMPOSE_STACK_PROCESS.wait(timeout=5)
|
|
171
145
|
_COMPOSE_STACK_PROCESS = None
|
|
@@ -183,14 +157,9 @@ def _wait_for_crowdsec_ready(timeout: int = 90) -> None:
|
|
|
183
157
|
_COMPOSE_STACK_PROCESS = None
|
|
184
158
|
logs = _collect_compose_logs(["crowdsec", "nginx", "backend"])
|
|
185
159
|
log_section = f"\n\nService logs:\n{logs}" if logs else ""
|
|
186
|
-
raise RuntimeError(
|
|
187
|
-
"WAF stack exited while waiting for CrowdSec to become ready"
|
|
188
|
-
f" (exit code {exit_code}).{log_section}"
|
|
189
|
-
)
|
|
160
|
+
raise RuntimeError(f"WAF stack exited while waiting for CrowdSec to become ready (exit code {exit_code}).{log_section}")
|
|
190
161
|
try:
|
|
191
|
-
result = _run_compose_exec(
|
|
192
|
-
["crowdsec", "cscli", "lapi", "status"], capture_output=True, check=False
|
|
193
|
-
)
|
|
162
|
+
result = _run_compose_exec(["crowdsec", "cscli", "lapi", "status"], capture_output=True, check=False)
|
|
194
163
|
if isinstance(result, subprocess.CompletedProcess) and result.returncode == 0:
|
|
195
164
|
LOGGER.info("CrowdSec API is ready")
|
|
196
165
|
return
|
|
@@ -264,10 +233,7 @@ def _run_nuclei_container(
|
|
|
264
233
|
|
|
265
234
|
if result.returncode != 0:
|
|
266
235
|
LOGGER.error("Nuclei container exited with code %s", result.returncode)
|
|
267
|
-
failure = (
|
|
268
|
-
f"Nuclei container exited with status {result.returncode}."
|
|
269
|
-
+ (f"\n\n{detail_text}" if detail_text else "")
|
|
270
|
-
)
|
|
236
|
+
failure = f"Nuclei container exited with status {result.returncode}." + (f"\n\n{detail_text}" if detail_text else "")
|
|
271
237
|
return (False, failure)
|
|
272
238
|
|
|
273
239
|
matches: list[dict[str, Any]] = []
|
|
@@ -290,10 +256,7 @@ def _run_nuclei_container(
|
|
|
290
256
|
if unmatched_lines:
|
|
291
257
|
info_lines.append("Nuclei produced output but no matches were recorded:\n" + "\n".join(unmatched_lines))
|
|
292
258
|
else:
|
|
293
|
-
info_lines.append(
|
|
294
|
-
"Nuclei completed successfully but reported zero matches. "
|
|
295
|
-
"The WAF rule likely did not block the request (missing HTTP 403)."
|
|
296
|
-
)
|
|
259
|
+
info_lines.append("Nuclei completed successfully but reported zero matches. The WAF rule likely did not block the request (missing HTTP 403).")
|
|
297
260
|
if stderr:
|
|
298
261
|
info_lines.append(f"stderr:\n{stderr}")
|
|
299
262
|
return (False, "\n\n".join(info_lines))
|
|
@@ -306,9 +269,7 @@ def _run_nuclei_container(
|
|
|
306
269
|
url = match.get("matched-at") or match.get("matchedAt") or target_url
|
|
307
270
|
summary_lines.append(f" - {template_id} matched at {url}")
|
|
308
271
|
if unmatched_lines:
|
|
309
|
-
summary_lines.append(
|
|
310
|
-
"Additional nuclei output:\n" + "\n".join(unmatched_lines)
|
|
311
|
-
)
|
|
272
|
+
summary_lines.append("Additional nuclei output:\n" + "\n".join(unmatched_lines))
|
|
312
273
|
if stderr:
|
|
313
274
|
summary_lines.append(f"stderr:\n{stderr}")
|
|
314
275
|
return (True, "\n".join(summary_lines))
|
|
@@ -479,9 +440,7 @@ def _validate_waf_rule(rule_yaml: str) -> list[types.TextContent]:
|
|
|
479
440
|
]
|
|
480
441
|
|
|
481
442
|
|
|
482
|
-
def _analyze_rule_item(
|
|
483
|
-
rule_item: Any, rule_path: str, warnings: list[str]
|
|
484
|
-
) -> tuple[bool, bool]:
|
|
443
|
+
def _analyze_rule_item(rule_item: Any, rule_path: str, warnings: list[str]) -> tuple[bool, bool]:
|
|
485
444
|
"""Recursively inspect rule items, track operator usage, and record warnings."""
|
|
486
445
|
if not isinstance(rule_item, dict):
|
|
487
446
|
return (False, False)
|
|
@@ -493,9 +452,7 @@ def _analyze_rule_item(
|
|
|
493
452
|
contains_or = has_or
|
|
494
453
|
|
|
495
454
|
if has_and and has_or:
|
|
496
|
-
warnings.append(
|
|
497
|
-
f"{location} mixes 'and' and 'or' operators at the same level; split them into separate nested blocks"
|
|
498
|
-
)
|
|
455
|
+
warnings.append(f"{location} mixes 'and' and 'or' operators at the same level; split them into separate nested blocks")
|
|
499
456
|
|
|
500
457
|
if has_and:
|
|
501
458
|
for i, sub_rule in enumerate(rule_item["and"]):
|
|
@@ -523,20 +480,13 @@ def _analyze_rule_item(
|
|
|
523
480
|
match_type = match.get("type", "")
|
|
524
481
|
match_value = match.get("value", "")
|
|
525
482
|
|
|
526
|
-
if (
|
|
527
|
-
match_type in CASE_SENSITIVE_MATCH_TYPES
|
|
528
|
-
and isinstance(match_value, str)
|
|
529
|
-
and any(c.isupper() for c in match_value)
|
|
530
|
-
):
|
|
483
|
+
if match_type in CASE_SENSITIVE_MATCH_TYPES and isinstance(match_value, str) and any(c.isupper() for c in match_value):
|
|
531
484
|
transforms = rule_item.get("transform", [])
|
|
532
|
-
has_lowercase = (
|
|
533
|
-
"lowercase" in transforms if isinstance(transforms, list) else False
|
|
534
|
-
)
|
|
485
|
+
has_lowercase = "lowercase" in transforms if isinstance(transforms, list) else False
|
|
535
486
|
|
|
536
487
|
if not has_lowercase:
|
|
537
488
|
warnings.append(
|
|
538
|
-
f"Match at {location} uses '{match_type}' with uppercase letters "
|
|
539
|
-
f"but no 'lowercase' transform - consider adding lowercase transform for case-insensitive matching"
|
|
489
|
+
f"Match at {location} uses '{match_type}' with uppercase letters but no 'lowercase' transform - consider adding lowercase transform for case-insensitive matching"
|
|
540
490
|
)
|
|
541
491
|
|
|
542
492
|
if isinstance(match_value, str):
|
|
@@ -544,9 +494,7 @@ def _analyze_rule_item(
|
|
|
544
494
|
sql_keywords = [kw for kw in SQL_KEYWORD_INDICATORS if kw in lower_value]
|
|
545
495
|
if sql_keywords:
|
|
546
496
|
keywords_str = ", ".join(sorted(set(sql_keywords)))
|
|
547
|
-
warnings.append(
|
|
548
|
-
f"Match at {location} contains SQL keyword(s) ({keywords_str}); instead of keyword blacklisting, detect escaping characters like quotes or semicolons"
|
|
549
|
-
)
|
|
497
|
+
warnings.append(f"Match at {location} contains SQL keyword(s) ({keywords_str}); instead of keyword blacklisting, detect escaping characters like quotes or semicolons")
|
|
550
498
|
|
|
551
499
|
transforms = rule_item.get("transform", [])
|
|
552
500
|
if isinstance(transforms, list) and "urldecode" in transforms:
|
|
@@ -590,9 +538,7 @@ def lint_waf_rule(rule_yaml: str) -> list[types.TextContent]:
|
|
|
590
538
|
name = parsed.get("name", "")
|
|
591
539
|
if isinstance(name, str):
|
|
592
540
|
if name.startswith("crowdsecurity/"):
|
|
593
|
-
warnings.append(
|
|
594
|
-
"Rule name starts with 'crowdsecurity/' which is reserved for official CrowdSec rules; consider using your own namespace"
|
|
595
|
-
)
|
|
541
|
+
warnings.append("Rule name starts with 'crowdsecurity/' which is reserved for official CrowdSec rules; consider using your own namespace")
|
|
596
542
|
else:
|
|
597
543
|
warnings.append("Field 'name' should be a string")
|
|
598
544
|
|
|
@@ -600,9 +546,7 @@ def lint_waf_rule(rule_yaml: str) -> list[types.TextContent]:
|
|
|
600
546
|
for i, rule in enumerate(parsed["rules"]):
|
|
601
547
|
rule_has_and, rule_has_or = _analyze_rule_item(rule, f"[{i}]", warnings)
|
|
602
548
|
if rule_has_and and rule_has_or:
|
|
603
|
-
warnings.append(
|
|
604
|
-
f"rules[{i}] uses both 'and' and 'or' operators somewhere in the block; CrowdSec cannot mix them in one rule, split the logic into separate rules"
|
|
605
|
-
)
|
|
549
|
+
warnings.append(f"rules[{i}] uses both 'and' and 'or' operators somewhere in the block; CrowdSec cannot mix them in one rule, split the logic into separate rules")
|
|
606
550
|
|
|
607
551
|
result_lines: list[str] = []
|
|
608
552
|
|
|
@@ -644,9 +588,7 @@ def _tool_get_waf_top_level_prompt(_: dict[str, Any] | None) -> list[types.TextC
|
|
|
644
588
|
]
|
|
645
589
|
except FileNotFoundError as exc:
|
|
646
590
|
LOGGER.error("WAF top-level prompt file not found at %s", WAF_TOP_LEVEL_PROMPT_FILE)
|
|
647
|
-
raise FileNotFoundError(
|
|
648
|
-
f"WAF top-level prompt file not found at {WAF_TOP_LEVEL_PROMPT_FILE}"
|
|
649
|
-
) from exc
|
|
591
|
+
raise FileNotFoundError(f"WAF top-level prompt file not found at {WAF_TOP_LEVEL_PROMPT_FILE}") from exc
|
|
650
592
|
except Exception as exc:
|
|
651
593
|
LOGGER.error("Error loading WAF top-level prompt: %s", exc)
|
|
652
594
|
raise RuntimeError(f"Error reading WAF top-level prompt file: {exc!s}") from exc
|
|
@@ -701,10 +643,7 @@ def _tool_generate_waf_rule(arguments: dict[str, Any] | None) -> list[types.Text
|
|
|
701
643
|
bool(nuclei_template),
|
|
702
644
|
)
|
|
703
645
|
if nuclei_template:
|
|
704
|
-
combined_prompt +=
|
|
705
|
-
"\n\n### Input Nuclei Template to Process:\n"
|
|
706
|
-
f"```yaml\n{nuclei_template}\n```"
|
|
707
|
-
)
|
|
646
|
+
combined_prompt += f"\n\n### Input Nuclei Template to Process:\n```yaml\n{nuclei_template}\n```"
|
|
708
647
|
|
|
709
648
|
return [
|
|
710
649
|
types.TextContent(
|
|
@@ -735,17 +674,10 @@ def _tool_generate_waf_tests(arguments: dict[str, Any] | None) -> list[types.Tex
|
|
|
735
674
|
combined_prompt = tests_prompt
|
|
736
675
|
|
|
737
676
|
if rule_filename:
|
|
738
|
-
combined_prompt +=
|
|
739
|
-
"\n\n### Rule Under Test\n"
|
|
740
|
-
f"The detection rule produced earlier is stored at: {rule_filename}\n"
|
|
741
|
-
"Use this exact path in the config.yaml `appsec-rules` list."
|
|
742
|
-
)
|
|
677
|
+
combined_prompt += f"\n\n### Rule Under Test\nThe detection rule produced earlier is stored at: {rule_filename}\nUse this exact path in the config.yaml `appsec-rules` list."
|
|
743
678
|
|
|
744
679
|
if nuclei_template:
|
|
745
|
-
combined_prompt +=
|
|
746
|
-
"\n\n### Input Nuclei Template to Adapt:\n"
|
|
747
|
-
f"```yaml\n{nuclei_template}\n```"
|
|
748
|
-
)
|
|
680
|
+
combined_prompt += f"\n\n### Input Nuclei Template to Adapt:\n```yaml\n{nuclei_template}\n```"
|
|
749
681
|
|
|
750
682
|
return [
|
|
751
683
|
types.TextContent(
|
|
@@ -761,6 +693,19 @@ def _tool_generate_waf_tests(arguments: dict[str, Any] | None) -> list[types.Tex
|
|
|
761
693
|
raise RuntimeError(f"Error generating WAF test prompt: {exc!s}") from exc
|
|
762
694
|
|
|
763
695
|
|
|
696
|
+
def _tool_get_waf_pr_prompt(_: dict[str, Any] | None) -> list[types.TextContent]:
|
|
697
|
+
try:
|
|
698
|
+
LOGGER.info("Serving WAF PR prompt content")
|
|
699
|
+
prompt_content = WAF_PR_PROMPT_FILE.read_text(encoding="utf-8")
|
|
700
|
+
return [types.TextContent(type="text", text=prompt_content)]
|
|
701
|
+
except FileNotFoundError as exc:
|
|
702
|
+
LOGGER.error("WAF PR prompt file not found at %s", WAF_PR_PROMPT_FILE)
|
|
703
|
+
raise FileNotFoundError(f"WAF PR prompt file not found at {WAF_PR_PROMPT_FILE}") from exc
|
|
704
|
+
except Exception as exc:
|
|
705
|
+
LOGGER.error("Error reading WAF PR prompt: %s", exc)
|
|
706
|
+
raise RuntimeError(f"Error reading WAF PR prompt: {exc!s}") from exc
|
|
707
|
+
|
|
708
|
+
|
|
764
709
|
def _tool_validate_waf_rule(arguments: dict[str, Any] | None) -> list[types.TextContent]:
|
|
765
710
|
if not arguments or "rule_yaml" not in arguments:
|
|
766
711
|
LOGGER.warning("Validation request missing 'rule_yaml' argument")
|
|
@@ -805,6 +750,156 @@ def _tool_deploy_waf_rule(_: dict[str, Any] | None) -> list[types.TextContent]:
|
|
|
805
750
|
raise RuntimeError(f"Error reading WAF deployment guide: {exc!s}") from exc
|
|
806
751
|
|
|
807
752
|
|
|
753
|
+
def _is_relative_to(base: Path, target: Path) -> bool:
|
|
754
|
+
try:
|
|
755
|
+
target.relative_to(base)
|
|
756
|
+
except ValueError:
|
|
757
|
+
return False
|
|
758
|
+
return True
|
|
759
|
+
|
|
760
|
+
def _require_non_empty_str(value: Any, field: str, message: str | None = None) -> str:
|
|
761
|
+
if not isinstance(value, str) or not value.strip():
|
|
762
|
+
raise ValueError(message or f"'{field}' must be a non-empty string")
|
|
763
|
+
return value.strip()
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _parse_collection_name(collection_name: str) -> tuple[str, str]:
|
|
767
|
+
normalized = collection_name.strip().lstrip("/")
|
|
768
|
+
if normalized.count("/") != 1:
|
|
769
|
+
raise ValueError("'collection_name' must be in the format 'author/name'")
|
|
770
|
+
author, name = normalized.split("/", 1)
|
|
771
|
+
if not author or not name:
|
|
772
|
+
raise ValueError("'collection_name' must be in the format 'author/name'")
|
|
773
|
+
return author, name
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _load_yaml_mapping(payload: str, error_context: str) -> dict[str, Any]:
|
|
777
|
+
try:
|
|
778
|
+
parsed = yaml.safe_load(payload) or {}
|
|
779
|
+
except yaml.YAMLError as exc:
|
|
780
|
+
raise RuntimeError(f"Failed to parse {error_context}: {exc}") from exc
|
|
781
|
+
if not isinstance(parsed, dict):
|
|
782
|
+
raise RuntimeError(f"{error_context} must be a mapping")
|
|
783
|
+
return parsed
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _update_collection_with_rule(
|
|
787
|
+
summary_lines: list[str],
|
|
788
|
+
hub_root: Path,
|
|
789
|
+
collection_name: str,
|
|
790
|
+
rule_yaml: str,
|
|
791
|
+
) -> None:
|
|
792
|
+
author, name = _parse_collection_name(collection_name)
|
|
793
|
+
collection_file = (hub_root / "collections" / author / f"{name}.yaml").resolve()
|
|
794
|
+
if not _is_relative_to(hub_root, collection_file):
|
|
795
|
+
raise ValueError("Resolved collection path escapes the hub directory")
|
|
796
|
+
if not collection_file.exists():
|
|
797
|
+
summary_lines.append(f"- Warning: collection file not found at {collection_file}. Add the rule manually.")
|
|
798
|
+
return
|
|
799
|
+
|
|
800
|
+
collection_payload = _load_yaml_mapping(
|
|
801
|
+
collection_file.read_text(encoding="utf-8"),
|
|
802
|
+
f"collection YAML at {collection_file}",
|
|
803
|
+
)
|
|
804
|
+
appsec_rules = collection_payload.get("appsec-rules")
|
|
805
|
+
if appsec_rules is None:
|
|
806
|
+
appsec_rules = []
|
|
807
|
+
collection_payload["appsec-rules"] = appsec_rules
|
|
808
|
+
if not isinstance(appsec_rules, list):
|
|
809
|
+
raise RuntimeError("'appsec-rules' in the collection file must be a list")
|
|
810
|
+
|
|
811
|
+
rule_payload = _load_yaml_mapping(rule_yaml, "rule YAML to read the rule name")
|
|
812
|
+
rule_name = rule_payload.get("name")
|
|
813
|
+
if not isinstance(rule_name, str) or not rule_name.strip():
|
|
814
|
+
raise RuntimeError("Rule YAML must include a non-empty 'name' field to update the collection")
|
|
815
|
+
rule_name = rule_name.strip()
|
|
816
|
+
|
|
817
|
+
if rule_name in appsec_rules:
|
|
818
|
+
summary_lines.append("- Collection already includes the rule; no update needed")
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
appsec_rules.append(rule_name)
|
|
822
|
+
collection_file.write_text(
|
|
823
|
+
yaml.safe_dump(collection_payload, sort_keys=False),
|
|
824
|
+
encoding="utf-8",
|
|
825
|
+
)
|
|
826
|
+
summary_lines.append(f"- Updated collection: added {rule_name} to {collection_file}")
|
|
827
|
+
|
|
828
|
+
def _tool_prepare_waf_pr(arguments: dict[str, Any] | None) -> list[types.TextContent]:
|
|
829
|
+
if not arguments:
|
|
830
|
+
LOGGER.warning("prepare_waf_pr called without arguments")
|
|
831
|
+
raise ValueError("Missing arguments payload")
|
|
832
|
+
|
|
833
|
+
hub_dir = _require_non_empty_str(
|
|
834
|
+
arguments.get("hub_dir"),
|
|
835
|
+
"hub_dir",
|
|
836
|
+
"'hub_dir' must be a non-empty string pointing to a local CrowdSec hub clone",
|
|
837
|
+
)
|
|
838
|
+
rule_yaml = _require_non_empty_str(
|
|
839
|
+
arguments.get("rule_yaml"),
|
|
840
|
+
"rule_yaml",
|
|
841
|
+
"'rule_yaml' must be a non-empty string containing the WAF rule",
|
|
842
|
+
)
|
|
843
|
+
test_config_yaml = _require_non_empty_str(
|
|
844
|
+
arguments.get("test_config_yaml"),
|
|
845
|
+
"test_config_yaml",
|
|
846
|
+
"'test_config_yaml' must be a non-empty string containing the test config.yaml",
|
|
847
|
+
)
|
|
848
|
+
test_nuclei_yaml = _require_non_empty_str(
|
|
849
|
+
arguments.get("test_nuclei_yaml"),
|
|
850
|
+
"test_nuclei_yaml",
|
|
851
|
+
"'test_nuclei_yaml' must be a non-empty string containing the adapted nuclei template",
|
|
852
|
+
)
|
|
853
|
+
rule_filename = _require_non_empty_str(arguments.get("rule_filename"), "rule_filename")
|
|
854
|
+
nuclei_filename = _require_non_empty_str(arguments.get("nuclei_filename"), "nuclei_filename")
|
|
855
|
+
collection_name = arguments.get("collection_name")
|
|
856
|
+
if collection_name is not None and (not isinstance(collection_name, str) or not collection_name.strip()):
|
|
857
|
+
raise ValueError("'collection_name' must be a non-empty string when provided")
|
|
858
|
+
|
|
859
|
+
hub_path = Path(hub_dir).expanduser()
|
|
860
|
+
hub_root = hub_path.resolve()
|
|
861
|
+
if not hub_path.exists() or not hub_path.is_dir():
|
|
862
|
+
raise ValueError(f"hub_dir does not exist or is not a directory: {hub_path}")
|
|
863
|
+
|
|
864
|
+
rule_rel_path = Path(rule_filename.strip())
|
|
865
|
+
|
|
866
|
+
rule_path = (hub_path / rule_rel_path).resolve()
|
|
867
|
+
if not _is_relative_to(hub_root, rule_path):
|
|
868
|
+
raise ValueError("Resolved rule path escapes the hub directory")
|
|
869
|
+
|
|
870
|
+
rule_path.parent.mkdir(parents=True, exist_ok=True)
|
|
871
|
+
rule_path.write_text(rule_yaml, encoding="utf-8")
|
|
872
|
+
|
|
873
|
+
test_dir_name = rule_path.stem
|
|
874
|
+
tests_root = (hub_path / ".appsec-tests" / test_dir_name).resolve()
|
|
875
|
+
if not _is_relative_to(hub_root, tests_root):
|
|
876
|
+
raise ValueError("Resolved test path escapes the hub directory")
|
|
877
|
+
tests_root.mkdir(parents=True, exist_ok=True)
|
|
878
|
+
|
|
879
|
+
nuclei_file = nuclei_filename.strip()
|
|
880
|
+
if not nuclei_file.endswith(".yaml"):
|
|
881
|
+
nuclei_file = f"{nuclei_file}.yaml"
|
|
882
|
+
|
|
883
|
+
config_path = tests_root / "config.yaml"
|
|
884
|
+
nuclei_path = tests_root / nuclei_file
|
|
885
|
+
config_path.write_text(test_config_yaml, encoding="utf-8")
|
|
886
|
+
nuclei_path.write_text(test_nuclei_yaml, encoding="utf-8")
|
|
887
|
+
|
|
888
|
+
summary_lines = [
|
|
889
|
+
"Prepared PR assets in the hub clone:",
|
|
890
|
+
f"- Rule: {rule_path}",
|
|
891
|
+
f"- Test config: {config_path}",
|
|
892
|
+
f"- Test nuclei: {nuclei_path}",
|
|
893
|
+
]
|
|
894
|
+
|
|
895
|
+
if collection_name:
|
|
896
|
+
_update_collection_with_rule(summary_lines, hub_root, collection_name, rule_yaml)
|
|
897
|
+
else:
|
|
898
|
+
summary_lines.append("- Warning: rule not added to appsec-virtual-patching collection. Remember to add it before opening the PR.")
|
|
899
|
+
|
|
900
|
+
return [types.TextContent(type="text", text="\n".join(summary_lines))]
|
|
901
|
+
|
|
902
|
+
|
|
808
903
|
def _tool_manage_waf_stack(arguments: dict[str, Any] | None) -> list[types.TextContent]:
|
|
809
904
|
try:
|
|
810
905
|
if not arguments:
|
|
@@ -830,9 +925,7 @@ def _tool_manage_waf_stack(arguments: dict[str, Any] | None) -> list[types.TextC
|
|
|
830
925
|
|
|
831
926
|
if not target_url:
|
|
832
927
|
LOGGER.error("WAF stack start returned no target URL and no explicit error")
|
|
833
|
-
raise RuntimeError(
|
|
834
|
-
"WAF stack start error: stack did not return a service URL and reported no specific error."
|
|
835
|
-
)
|
|
928
|
+
raise RuntimeError("WAF stack start error: stack did not return a service URL and reported no specific error.")
|
|
836
929
|
|
|
837
930
|
return [
|
|
838
931
|
types.TextContent(
|
|
@@ -956,6 +1049,7 @@ def _tool_run_waf_tests(arguments: dict[str, Any] | None) -> list[types.TextCont
|
|
|
956
1049
|
except Exception as stop_exc: # pragma: no cover - best effort cleanup
|
|
957
1050
|
LOGGER.warning("Failed to stop WAF stack during cleanup: %s", stop_exc)
|
|
958
1051
|
|
|
1052
|
+
|
|
959
1053
|
def _search_repo_for_cve(repo_path: Path, cve: str) -> list[Path]:
|
|
960
1054
|
"""Return files whose name contains the CVE identifier (case-insensitive)."""
|
|
961
1055
|
lower_token = cve.lower()
|
|
@@ -1002,9 +1096,7 @@ def _tool_fetch_nuclei_exploit(arguments: dict[str, Any] | None) -> list[types.T
|
|
|
1002
1096
|
|
|
1003
1097
|
if repo_path.exists():
|
|
1004
1098
|
if not (repo_path / ".git").exists():
|
|
1005
|
-
raise RuntimeError(
|
|
1006
|
-
f"Destination {repo_path} exists but is not a git repository"
|
|
1007
|
-
)
|
|
1099
|
+
raise RuntimeError(f"Destination {repo_path} exists but is not a git repository")
|
|
1008
1100
|
git_cmd = ["git", "-C", str(repo_path), "pull", "--ff-only"]
|
|
1009
1101
|
else:
|
|
1010
1102
|
git_cmd = ["git", "clone", "--depth", "1", cleaned_url, str(repo_path)]
|
|
@@ -1038,9 +1130,7 @@ def _tool_fetch_nuclei_exploit(arguments: dict[str, Any] | None) -> list[types.T
|
|
|
1038
1130
|
except OSError as read_err:
|
|
1039
1131
|
findings.append(f" (failed to read {relative_path}: {read_err})")
|
|
1040
1132
|
continue
|
|
1041
|
-
rendered_templates.append(
|
|
1042
|
-
f"### {cleaned_url} :: {relative_path}\n```yaml\n{file_contents}\n```"
|
|
1043
|
-
)
|
|
1133
|
+
rendered_templates.append(f"### {cleaned_url} :: {relative_path}\n```yaml\n{file_contents}\n```")
|
|
1044
1134
|
total_files += 1
|
|
1045
1135
|
|
|
1046
1136
|
if total_files == 0:
|
|
@@ -1049,10 +1139,7 @@ def _tool_fetch_nuclei_exploit(arguments: dict[str, Any] | None) -> list[types.T
|
|
|
1049
1139
|
return [
|
|
1050
1140
|
types.TextContent(
|
|
1051
1141
|
type="text",
|
|
1052
|
-
text=(
|
|
1053
|
-
f"No files containing {cve} were found in the provided repositories."
|
|
1054
|
-
f"{detail_section}"
|
|
1055
|
-
),
|
|
1142
|
+
text=(f"No files containing {cve} were found in the provided repositories.{detail_section}"),
|
|
1056
1143
|
)
|
|
1057
1144
|
]
|
|
1058
1145
|
|
|
@@ -1099,9 +1186,7 @@ def _tool_curl_waf_endpoint(arguments: dict[str, Any] | None) -> list[types.Text
|
|
|
1099
1186
|
if not path.startswith("/"):
|
|
1100
1187
|
if "://" in path:
|
|
1101
1188
|
parsed = urllib.parse.urlparse(path)
|
|
1102
|
-
path = urllib.parse.urlunparse(
|
|
1103
|
-
("", "", parsed.path or "/", parsed.params, parsed.query, parsed.fragment)
|
|
1104
|
-
)
|
|
1189
|
+
path = urllib.parse.urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, parsed.fragment))
|
|
1105
1190
|
else:
|
|
1106
1191
|
path = "/" + path
|
|
1107
1192
|
|
|
@@ -1109,9 +1194,7 @@ def _tool_curl_waf_endpoint(arguments: dict[str, Any] | None) -> list[types.Text
|
|
|
1109
1194
|
LOGGER.warning("curl_waf_endpoint received non-string body payload")
|
|
1110
1195
|
raise ValueError("'body' must be a string when provided")
|
|
1111
1196
|
|
|
1112
|
-
LOGGER.info(
|
|
1113
|
-
"curl_waf_endpoint executing %s request to %s (timeout=%s)", method, path, timeout
|
|
1114
|
-
)
|
|
1197
|
+
LOGGER.info("curl_waf_endpoint executing %s request to %s (timeout=%s)", method, path, timeout)
|
|
1115
1198
|
try:
|
|
1116
1199
|
response = requests.request(
|
|
1117
1200
|
method=method,
|
|
@@ -1124,12 +1207,7 @@ def _tool_curl_waf_endpoint(arguments: dict[str, Any] | None) -> list[types.Text
|
|
|
1124
1207
|
raise RuntimeError(f"HTTP request failed: {req_err}") from req_err
|
|
1125
1208
|
|
|
1126
1209
|
header_lines = "\n".join(f"{k}: {v}" for k, v in response.headers.items())
|
|
1127
|
-
response_text =
|
|
1128
|
-
f">>> {method} http://localhost:8081{path}\n"
|
|
1129
|
-
f"Status: {response.status_code}\n"
|
|
1130
|
-
f"Headers:\n{header_lines}\n\n"
|
|
1131
|
-
f"Body:\n{response.text}"
|
|
1132
|
-
)
|
|
1210
|
+
response_text = f">>> {method} http://localhost:8081{path}\nStatus: {response.status_code}\nHeaders:\n{header_lines}\n\nBody:\n{response.text}"
|
|
1133
1211
|
|
|
1134
1212
|
LOGGER.info(
|
|
1135
1213
|
"curl_waf_endpoint completed with status %s for %s %s",
|
|
@@ -1154,10 +1232,12 @@ WAF_TOOL_HANDLERS: dict[str, ToolHandler] = {
|
|
|
1154
1232
|
"get_waf_prompt": _tool_get_waf_prompt,
|
|
1155
1233
|
"get_waf_examples": _tool_get_waf_examples,
|
|
1156
1234
|
"generate_waf_rule": _tool_generate_waf_rule,
|
|
1235
|
+
"get_waf_pr_prompt": _tool_get_waf_pr_prompt,
|
|
1157
1236
|
"generate_waf_tests": _tool_generate_waf_tests,
|
|
1158
1237
|
"validate_waf_rule": _tool_validate_waf_rule,
|
|
1159
1238
|
"lint_waf_rule": _tool_lint_waf_rule,
|
|
1160
1239
|
"deploy_waf_rule": _tool_deploy_waf_rule,
|
|
1240
|
+
"prepare_waf_pr": _tool_prepare_waf_pr,
|
|
1161
1241
|
"fetch_nuclei_exploit": _tool_fetch_nuclei_exploit,
|
|
1162
1242
|
"manage_waf_stack": _tool_manage_waf_stack,
|
|
1163
1243
|
"run_waf_tests": _tool_run_waf_tests,
|
|
@@ -1224,6 +1304,15 @@ WAF_TOOLS: list[types.Tool] = [
|
|
|
1224
1304
|
"additionalProperties": False,
|
|
1225
1305
|
},
|
|
1226
1306
|
),
|
|
1307
|
+
types.Tool(
|
|
1308
|
+
name="get_waf_pr_prompt",
|
|
1309
|
+
description="Get the WAF PR preparation prompt for writing test assets and drafting a PR comment",
|
|
1310
|
+
inputSchema={
|
|
1311
|
+
"type": "object",
|
|
1312
|
+
"properties": {},
|
|
1313
|
+
"additionalProperties": False,
|
|
1314
|
+
},
|
|
1315
|
+
),
|
|
1227
1316
|
types.Tool(
|
|
1228
1317
|
name="run_waf_tests",
|
|
1229
1318
|
description="Start the WAF harness and execute the provided nuclei test template against it."
|
|
@@ -1283,6 +1372,52 @@ WAF_TOOLS: list[types.Tool] = [
|
|
|
1283
1372
|
"additionalProperties": False,
|
|
1284
1373
|
},
|
|
1285
1374
|
),
|
|
1375
|
+
types.Tool(
|
|
1376
|
+
name="prepare_waf_pr",
|
|
1377
|
+
description=("Help prepare a pull request in a local clone of the CrowdSec hub repository by adding the generated WAF rule and associated test files."),
|
|
1378
|
+
inputSchema={
|
|
1379
|
+
"type": "object",
|
|
1380
|
+
"properties": {
|
|
1381
|
+
"hub_dir": {
|
|
1382
|
+
"type": "string",
|
|
1383
|
+
"description": "Path to the local clone of the CrowdSec hub repository",
|
|
1384
|
+
},
|
|
1385
|
+
"rule_yaml": {
|
|
1386
|
+
"type": "string",
|
|
1387
|
+
"description": "Generated WAF rule YAML content",
|
|
1388
|
+
},
|
|
1389
|
+
"test_config_yaml": {
|
|
1390
|
+
"type": "string",
|
|
1391
|
+
"description": "Generated AppSec test config.yaml content",
|
|
1392
|
+
},
|
|
1393
|
+
"test_nuclei_yaml": {
|
|
1394
|
+
"type": "string",
|
|
1395
|
+
"description": "Generated adapted nuclei template content",
|
|
1396
|
+
},
|
|
1397
|
+
"rule_filename": {
|
|
1398
|
+
"type": "string",
|
|
1399
|
+
"description": "Relative path (from hub root) for the rule file",
|
|
1400
|
+
},
|
|
1401
|
+
"nuclei_filename": {
|
|
1402
|
+
"type": "string",
|
|
1403
|
+
"description": "Filename for the nuclei test template",
|
|
1404
|
+
},
|
|
1405
|
+
"collection_name": {
|
|
1406
|
+
"type": "string",
|
|
1407
|
+
"description": "Target collection in the format author/name (mapped to collections/author/name.yaml)",
|
|
1408
|
+
},
|
|
1409
|
+
},
|
|
1410
|
+
"required": [
|
|
1411
|
+
"hub_dir",
|
|
1412
|
+
"rule_yaml",
|
|
1413
|
+
"test_config_yaml",
|
|
1414
|
+
"test_nuclei_yaml",
|
|
1415
|
+
"rule_filename",
|
|
1416
|
+
"nuclei_filename",
|
|
1417
|
+
],
|
|
1418
|
+
"additionalProperties": False,
|
|
1419
|
+
},
|
|
1420
|
+
),
|
|
1286
1421
|
types.Tool(
|
|
1287
1422
|
name="fetch_nuclei_exploit",
|
|
1288
1423
|
description="Retrieve nuclei templates from the official repository for a CVE to help with generation of WAF rules",
|
|
@@ -1386,6 +1521,12 @@ WAF_RESOURCES: list[types.Resource] = [
|
|
|
1386
1521
|
description="Instructions for producing config.yaml and adapted Nuclei templates for WAF testing",
|
|
1387
1522
|
mimeType="text/plain",
|
|
1388
1523
|
),
|
|
1524
|
+
types.Resource(
|
|
1525
|
+
uri="file://prompts/prompt-waf-pr.txt",
|
|
1526
|
+
name="WAF PR Preparation Prompt",
|
|
1527
|
+
description="Short guidance for preparing WAF PR assets and drafting a PR comment",
|
|
1528
|
+
mimeType="text/plain",
|
|
1529
|
+
),
|
|
1389
1530
|
]
|
|
1390
1531
|
|
|
1391
1532
|
WAF_RESOURCE_READERS: dict[str, Callable[[], str]] = {
|
|
@@ -1394,6 +1535,7 @@ WAF_RESOURCE_READERS: dict[str, Callable[[], str]] = {
|
|
|
1394
1535
|
"file://prompts/prompt-waf-examples.txt": lambda: WAF_EXAMPLES_FILE.read_text(encoding="utf-8"),
|
|
1395
1536
|
"file://prompts/prompt-waf-deploy.txt": lambda: WAF_DEPLOY_FILE.read_text(encoding="utf-8"),
|
|
1396
1537
|
"file://prompts/prompt-waf-tests.txt": lambda: WAF_TESTS_PROMPT_FILE.read_text(encoding="utf-8"),
|
|
1538
|
+
"file://prompts/prompt-waf-pr.txt": lambda: WAF_PR_PROMPT_FILE.read_text(encoding="utf-8"),
|
|
1397
1539
|
}
|
|
1398
1540
|
|
|
1399
1541
|
REGISTRY.register_tools(WAF_TOOL_HANDLERS, WAF_TOOLS)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
You are preparing a CrowdSec WAF pull request in a local hub clone.
|
|
2
|
+
|
|
3
|
+
Use these instructions as the authoritative spec for the test assets you must add. Create the required files under `.appsec-tests/<test_name>/` and ensure `config.yaml` references the exact rule path that was generated.
|
|
4
|
+
|
|
5
|
+
When the files are ready, draft a short PR comment the user can paste. The comment should include:
|
|
6
|
+
- A one-line summary of the rule and tests added.
|
|
7
|
+
- A list of files added or updated.
|
|
8
|
+
- How to run or validate the tests (short, practical guidance).
|
|
9
|
+
|
|
10
|
+
Return only the PR comment text, ready to paste, with no extra explanations.
|
|
@@ -8,6 +8,14 @@ You are an expert in generating automated test assets for the CrowdSec WAF harne
|
|
|
8
8
|
- `./appsec-rules/crowdsecurity/base-config.yaml`
|
|
9
9
|
- The path to the generated rule (e.g. `./appsec-rules/crowdsecurity/vpatch-CVE-2020-17496.yaml`).
|
|
10
10
|
- `nuclei_template` is the filename of the adapted Nuclei template (e.g. `CVE-2020-17496.yaml`).
|
|
11
|
+
- The path in `appsec-rules` is relative to the hub root, because the rule is not on the hub yet.
|
|
12
|
+
- Use this template and replace the placeholders only:
|
|
13
|
+
```
|
|
14
|
+
appsec-rules:
|
|
15
|
+
- ./appsec-rules/crowdsecurity/base-config.yaml
|
|
16
|
+
- <PUT_THE_NEW_RULE_PATH_HERE>
|
|
17
|
+
nuclei_template: <PUT_THE_TEST_TEMPLATE_FILENAME_HERE>
|
|
18
|
+
```
|
|
11
19
|
|
|
12
20
|
2. **Adapted Nuclei Template**
|
|
13
21
|
- Preserve the original request structure that exercises the vulnerability.
|
|
@@ -27,6 +35,10 @@ You are an expert in generating automated test assets for the CrowdSec WAF harne
|
|
|
27
35
|
- Respect YAML indentation (two spaces) and quote strings that contain special characters.
|
|
28
36
|
- Mirror the case and structure of URIs, headers, and payloads from the input template.
|
|
29
37
|
- Provide helpful inline comments only when strictly necessary to explain intentional failures.
|
|
38
|
+
- Place test assets in the hub under `.appsec-tests/<test_name>/`:
|
|
39
|
+
- `config.yaml`
|
|
40
|
+
- `<test_name>.yaml` (the adapted nuclei template)
|
|
41
|
+
- If the rule is for virtual patching, use `CVE-YYYY-XYZ` as the test name and `vpatch-CVE-YYYY-XYZ` as the rule name.
|
|
30
42
|
|
|
31
43
|
## Output Format
|
|
32
44
|
|
|
@@ -26,6 +26,8 @@ Once you have successfully generated and validated a WAF rule, propose the user
|
|
|
26
26
|
- Create and validate a test for the rule - allows contribution to the hub:
|
|
27
27
|
- Use tool `generate_waf_tests` to get instructions on test creation
|
|
28
28
|
- Use tool `run_waf_tests` to run and validate the tests
|
|
29
|
-
- Once done,
|
|
29
|
+
- Once done, suggest help preparing a PR
|
|
30
|
+
- Use tool `get_waf_pr_prompt` to guide PR preparation and draft a PR comment
|
|
31
|
+
- Use tool `prepare_waf_pr` to write the rule and test assets into a local hub clone
|
|
30
32
|
- Help the user to deploy the rule on his setup:
|
|
31
33
|
- Use tool `deploy_waf_rule` to get detailed instructions and guide the user.
|
|
@@ -45,6 +45,7 @@ src/crowdsec_local_mcp/prompts/prompt-scenario-examples.txt
|
|
|
45
45
|
src/crowdsec_local_mcp/prompts/prompt-scenario.txt
|
|
46
46
|
src/crowdsec_local_mcp/prompts/prompt-waf-deploy.txt
|
|
47
47
|
src/crowdsec_local_mcp/prompts/prompt-waf-examples.txt
|
|
48
|
+
src/crowdsec_local_mcp/prompts/prompt-waf-pr.txt
|
|
48
49
|
src/crowdsec_local_mcp/prompts/prompt-waf-tests.txt
|
|
49
50
|
src/crowdsec_local_mcp/prompts/prompt-waf-top-level.txt
|
|
50
51
|
src/crowdsec_local_mcp/prompts/prompt-waf.txt
|
|
@@ -147,6 +147,7 @@ dev = [
|
|
|
147
147
|
{ name = "basedpyright" },
|
|
148
148
|
{ name = "pytest" },
|
|
149
149
|
{ name = "ruff" },
|
|
150
|
+
{ name = "setuptools-scm" },
|
|
150
151
|
{ name = "types-pyyaml" },
|
|
151
152
|
{ name = "types-requests" },
|
|
152
153
|
]
|
|
@@ -164,6 +165,7 @@ dev = [
|
|
|
164
165
|
{ name = "basedpyright", specifier = ">=1.25.0" },
|
|
165
166
|
{ name = "pytest", specifier = ">=8.3.0" },
|
|
166
167
|
{ name = "ruff", specifier = ">=0.9.3" },
|
|
168
|
+
{ name = "setuptools-scm", specifier = ">=8" },
|
|
167
169
|
{ name = "types-pyyaml", specifier = ">=6.0.12.20241230" },
|
|
168
170
|
{ name = "types-requests", specifier = ">=2.32.0.20241016" },
|
|
169
171
|
]
|
|
@@ -627,6 +629,28 @@ wheels = [
|
|
|
627
629
|
{ url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636 },
|
|
628
630
|
]
|
|
629
631
|
|
|
632
|
+
[[package]]
|
|
633
|
+
name = "setuptools"
|
|
634
|
+
version = "80.9.0"
|
|
635
|
+
source = { registry = "https://pypi.org/simple" }
|
|
636
|
+
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 }
|
|
637
|
+
wheels = [
|
|
638
|
+
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 },
|
|
639
|
+
]
|
|
640
|
+
|
|
641
|
+
[[package]]
|
|
642
|
+
name = "setuptools-scm"
|
|
643
|
+
version = "9.2.2"
|
|
644
|
+
source = { registry = "https://pypi.org/simple" }
|
|
645
|
+
dependencies = [
|
|
646
|
+
{ name = "packaging" },
|
|
647
|
+
{ name = "setuptools" },
|
|
648
|
+
]
|
|
649
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7b/b1/19587742aad604f1988a8a362e660e8c3ac03adccdb71c96d86526e5eb62/setuptools_scm-9.2.2.tar.gz", hash = "sha256:1c674ab4665686a0887d7e24c03ab25f24201c213e82ea689d2f3e169ef7ef57", size = 203385 }
|
|
650
|
+
wheels = [
|
|
651
|
+
{ url = "https://files.pythonhosted.org/packages/3d/ea/ac2bf868899d0d2e82ef72d350d97a846110c709bacf2d968431576ca915/setuptools_scm-9.2.2-py3-none-any.whl", hash = "sha256:30e8f84d2ab1ba7cb0e653429b179395d0c33775d54807fc5f1dd6671801aef7", size = 62975 },
|
|
652
|
+
]
|
|
653
|
+
|
|
630
654
|
[[package]]
|
|
631
655
|
name = "sniffio"
|
|
632
656
|
version = "1.3.1"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.7.0.post1.dev0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/crowdsec_logo.png
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tests/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{crowdsec_local_mcp-0.7.0.post1.dev0 → crowdsec_local_mcp-0.8.0.post1.dev0}/tools/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|