diff --git a/scripts/check-extension-manifests.sh b/scripts/check-extension-manifests.sh index 369f3788..082b34dd 100755 --- a/scripts/check-extension-manifests.sh +++ b/scripts/check-extension-manifests.sh @@ -27,19 +27,9 @@ PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover \ # Tools that cannot publish a manifest yet, with the reason. A tool is listed # here only when the gap is in the host contract rather than in the tool, and -# listing it does not make the tool installable — IronClaw refuses an HTTP Basic -# credential today whether or not a manifest is published. Remove an entry when -# the underlying gap closes. -# -# wazuh — authenticates with HTTP Basic. v3 credential injection models -# header / query-param / path-placeholder / JSON-pointer targets -# and has no Basic variant, because Basic needs username + -# base64(user:pass) composition that the host cannot express. -# wordpress — supports alternative WordPress Basic and WooCommerce query-param -# credentials on one tool. Query-param injection is expressible, -# but the Basic alternative is not; dropping it would publish a -# partially working manifest. -exempt="wazuh wordpress" +# listing it does not make the tool installable. Remove an entry when the +# underlying gap closes. +exempt="" failed=0 checked=0 diff --git a/scripts/generate-extension-manifest.py b/scripts/generate-extension-manifest.py index e90afdff..f07cf987 100755 --- a/scripts/generate-extension-manifest.py +++ b/scripts/generate-extension-manifest.py @@ -70,10 +70,10 @@ def toml_string(value: str) -> str: def credential_injection(name: str, location: dict, handle: str) -> dict: """Map a published credential location onto the v3 injection contract. - v3 models header / query-param / path-placeholder / JSON-pointer injection. - It has no HTTP Basic variant: Basic needs username + base64(user:secret) - composition, which the host cannot express, so a `basic` credential is a hard - error here rather than a package that installs and can never authenticate. + v3 models header / query-param / path-placeholder / JSON-pointer / basic + injection. A `basic` location carries only the username: the host owns the + `username:secret` join and the base64 encoding, so a package can never ship + a pre-encoded credential or smuggle a second field past the colon. """ if not isinstance(location, dict): raise SystemExit(f"{name}: credential {handle!r} location must be an object") @@ -106,10 +106,23 @@ def credential_injection(name: str, location: dict, handle: str) -> dict: f"without a name" ) return {"type": "query_param", "name": parameter.strip()} + if kind == "basic": + username = location.get("username") + if not isinstance(username, str) or not username.strip(): + raise SystemExit( + f"{name}: credential {handle!r} declares a basic location " + f"without a username" + ) + username = username.strip() + if ":" in username: + raise SystemExit( + f"{name}: credential {handle!r} declares a basic username " + f"containing ':', which RFC 7617 reserves as the delimiter" + ) + return {"type": "basic", "username": username} raise SystemExit( f"{name}: credential {handle!r} declares location type {kind!r}, which the " - f"host cannot inject. Supported: 'bearer', 'header', 'query_param'. " - f"(v3 injection has no HTTP Basic variant.)" + f"host cannot inject. Supported: 'bearer', 'header', 'query_param', 'basic'." ) @@ -405,10 +418,19 @@ def generate_manifest(caps: dict, name: str, crate_name: str, version: str) -> s f'{{ type = "header", name = {toml_string(injection["name"])}' f"{prefix} }}" ) - else: + elif injection["type"] == "basic": + injection_toml = ( + f'{{ type = "basic", username = {toml_string(injection["username"])} }}' + ) + elif injection["type"] == "query_param": injection_toml = ( f'{{ type = "query_param", name = {toml_string(injection["name"])} }}' ) + else: + raise SystemExit( + f"{name}: credential {handle_name!r} produced an unsupported " + f"injection type {injection['type']!r}" + ) scope_line = "" if scopes is not None: scope_values = ", ".join(toml_string(scope) for scope in scopes) diff --git a/scripts/test_generate_extension_manifest.py b/scripts/test_generate_extension_manifest.py index 03c50dd7..406def45 100644 --- a/scripts/test_generate_extension_manifest.py +++ b/scripts/test_generate_extension_manifest.py @@ -23,7 +23,7 @@ # Wazuh and WordPress use HTTP Basic, which the v3 injection contract cannot # express. The production check carries the same documented exemptions. -EXEMPT_TOOLS = {"wazuh", "wordpress"} +EXEMPT_TOOLS: set[str] = set() def source_http(caps: dict) -> dict: @@ -62,6 +62,11 @@ def expected_injection(credential: dict) -> dict: "type": "header", "name": location.get("name", "authorization").strip().lower(), } + if location["type"] == "basic": + return { + "type": "basic", + "username": location["username"].strip(), + } return { "type": "query_param", "name": location["name"].strip(), @@ -394,11 +399,35 @@ def test_generated_manifests_are_valid_toml(self) -> None: for credential in tool.get("credentials", []): self.assertEqual(credential["scopes"], scopes) - def test_exempt_tools_fail_for_the_documented_basic_auth_gap(self) -> None: - for tool_name in EXEMPT_TOOLS: + def test_basic_credentials_publish_the_username_and_never_the_secret(self) -> None: + expected = { + "wazuh": {"admin", "wazuh-wui"}, + "wordpress": {"YOUR_WP_USERNAME"}, + } + for tool_name, usernames in expected.items(): with self.subTest(tool=tool_name): - with self.assertRaisesRegex(SystemExit, "no HTTP Basic variant"): - self.generated_tool(tool_name) + _, manifest = self.generated_tool(tool_name) + published = set( + re.findall( + r'injection = \{ type = "basic", username = "([^"]+)" \}', + manifest, + ) + ) + self.assertEqual(published, usernames) + + def test_a_basic_username_containing_the_delimiter_is_rejected(self) -> None: + with self.assertRaisesRegex(SystemExit, "RFC 7617 reserves"): + GENERATOR.credential_injection( + "fixture", + {"type": "basic", "username": "user:extra"}, + "fixture_password", + ) + + def test_a_basic_location_without_a_username_is_rejected(self) -> None: + with self.assertRaisesRegex(SystemExit, "without a username"): + GENERATOR.credential_injection( + "fixture", {"type": "basic", "username": " "}, "fixture_password" + ) if __name__ == "__main__": diff --git a/tools/wordpress/schemas/wordpress/invoke.input.v1.json b/tools/wordpress/schemas/wordpress/invoke.input.v1.json new file mode 100644 index 00000000..a5163415 --- /dev/null +++ b/tools/wordpress/schemas/wordpress/invoke.input.v1.json @@ -0,0 +1,215 @@ +{ + "type": "object", + "required": ["command"], + "oneOf": [ + { + "properties": { + "command": { "const": "wp_request" }, + "site_url": { "type": "string", "description": "Your site host, e.g. 'mystore.com'. Must match the host baked into the tool at install." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"], "description": "HTTP method." }, + "endpoint": { "type": "string", "description": "REST path starting with '/wp-json/', e.g. '/wp-json/wp/v2/pages' or '/wp-json/wc/v3/coupons'." }, + "query": { "type": "object", "description": "Query parameters as a flat object; values are stringified." }, + "body": { "type": "object", "description": "JSON body for POST/PUT (ignored for GET/DELETE)." } + }, + "required": ["command", "site_url", "method", "endpoint"] + }, + { + "properties": { + "command": { "const": "list_posts" }, + "site_url": { "type": "string", "description": "Your site host, e.g. 'mystore.com'." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "page": { "type": "integer", "description": "Page number (1-based).", "minimum": 1 }, + "per_page": { "type": "integer", "description": "Items per page (1-100, clamped).", "minimum": 1 }, + "search": { "type": "string", "description": "Full-text search term." }, + "status": { "type": "string", "description": "Post status filter, e.g. 'publish', 'draft', 'any'." } + }, + "required": ["command", "site_url"] + }, + { + "properties": { + "command": { "const": "get_post" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Post id." } + }, + "required": ["command", "site_url", "id"] + }, + { + "properties": { + "command": { "const": "create_post" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "data": { "type": "object", "description": "Post fields, e.g. {\"title\":\"…\",\"content\":\"…\",\"status\":\"draft\"}." } + }, + "required": ["command", "site_url", "data"] + }, + { + "properties": { + "command": { "const": "update_post" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Post id." }, + "data": { "type": "object", "description": "Fields to change (partial update)." } + }, + "required": ["command", "site_url", "id", "data"] + }, + { + "properties": { + "command": { "const": "delete_post" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Post id." }, + "force": { "type": "boolean", "description": "true = delete permanently; false/omitted = move to trash (default)." } + }, + "required": ["command", "site_url", "id"] + }, + { + "properties": { + "command": { "const": "list_products" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "page": { "type": "integer", "minimum": 1, "description": "Page number (1-based)." }, + "per_page": { "type": "integer", "minimum": 1, "description": "Items per page (1-100)." }, + "search": { "type": "string", "description": "Search term." }, + "status": { "type": "string", "description": "Product status, e.g. 'publish', 'draft'." } + }, + "required": ["command", "site_url"] + }, + { + "properties": { + "command": { "const": "get_product" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Product id." } + }, + "required": ["command", "site_url", "id"] + }, + { + "properties": { + "command": { "const": "create_product" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "data": { "type": "object", "description": "Product fields, e.g. {\"name\":\"…\",\"type\":\"simple\",\"regular_price\":\"9.99\"}." } + }, + "required": ["command", "site_url", "data"] + }, + { + "properties": { + "command": { "const": "update_product" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Product id." }, + "data": { "type": "object", "description": "Fields to change (partial update)." } + }, + "required": ["command", "site_url", "id", "data"] + }, + { + "properties": { + "command": { "const": "delete_product" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Product id." }, + "force": { "type": "boolean", "description": "true = delete permanently; false/omitted = trash (default). Note: WooCommerce may require force=true for some resources." } + }, + "required": ["command", "site_url", "id"] + }, + { + "properties": { + "command": { "const": "upload_media" }, + "site_url": { "type": "string", "description": "Your site host, e.g. 'mystore.com'." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "filename": { "type": "string", "description": "File name including extension, e.g. 'logo.png'. Used for the Content-Disposition and to guess the MIME type." }, + "content_base64": { "type": "string", "description": "The file's bytes, base64-encoded. A 'data:...;base64,' prefix is accepted. Keep files small (a few MB) — very large files can exceed the WASM sandbox memory limit." }, + "mime": { "type": "string", "description": "Optional explicit MIME type (e.g. 'image/png'). Overrides the guess from the filename extension." }, + "title": { "type": "string", "description": "Optional media title." }, + "alt_text": { "type": "string", "description": "Optional alt text (accessibility)." }, + "caption": { "type": "string", "description": "Optional caption." }, + "post": { "type": "integer", "description": "Optional parent post id to attach the media to." } + }, + "required": ["command", "site_url", "filename", "content_base64"] + }, + { + "properties": { + "command": { "const": "list_media" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "page": { "type": "integer", "minimum": 1, "description": "Page number (1-based)." }, + "per_page": { "type": "integer", "minimum": 1, "description": "Items per page (1-100)." }, + "search": { "type": "string", "description": "Search term." } + }, + "required": ["command", "site_url"] + }, + { + "properties": { + "command": { "const": "get_media" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Media (attachment) id." } + }, + "required": ["command", "site_url", "id"] + }, + { + "properties": { + "command": { "const": "update_media" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Media (attachment) id." }, + "data": { "type": "object", "description": "Fields to change, e.g. {\"title\":\"…\",\"alt_text\":\"…\",\"caption\":\"…\"}." } + }, + "required": ["command", "site_url", "id", "data"] + }, + { + "properties": { + "command": { "const": "delete_media" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Media (attachment) id." }, + "force": { "type": "boolean", "description": "WordPress cannot trash media, so this defaults to true (permanent delete). Set false only if you have a plugin that supports trashing media." } + }, + "required": ["command", "site_url", "id"] + }, + { + "properties": { + "command": { "const": "list_orders" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "page": { "type": "integer", "minimum": 1, "description": "Page number (1-based)." }, + "per_page": { "type": "integer", "minimum": 1, "description": "Items per page (1-100)." }, + "search": { "type": "string", "description": "Search term." }, + "status": { "type": "string", "description": "Order status, e.g. 'processing', 'completed', 'any'." } + }, + "required": ["command", "site_url"] + }, + { + "properties": { + "command": { "const": "get_order" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Order id." } + }, + "required": ["command", "site_url", "id"] + }, + { + "properties": { + "command": { "const": "update_order" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "id": { "type": "integer", "description": "Order id." }, + "data": { "type": "object", "description": "Fields to change, e.g. {\"status\":\"completed\"}." } + }, + "required": ["command", "site_url", "id", "data"] + }, + { + "properties": { + "command": { "const": "list_customers" }, + "site_url": { "type": "string", "description": "Your site host." }, + "api_prefix": { "type": "string", "description": "Optional REST path prefix if your site does not use the default '/wp-json/' (e.g. '/api/'). Must match the prefix baked into the tool at install." }, + "page": { "type": "integer", "minimum": 1, "description": "Page number (1-based)." }, + "per_page": { "type": "integer", "minimum": 1, "description": "Items per page (1-100)." }, + "search": { "type": "string", "description": "Search term (email/name)." } + }, + "required": ["command", "site_url"] + } + ] +} diff --git a/tools/wordpress/schemas/wordpress/raw_output.v1.json b/tools/wordpress/schemas/wordpress/raw_output.v1.json new file mode 100644 index 00000000..ab589198 --- /dev/null +++ b/tools/wordpress/schemas/wordpress/raw_output.v1.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Command-specific WordPress REST or WooCommerce JSON for post, product, media, order, and customer operations." +}