Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 3 additions & 13 deletions scripts/check-extension-manifests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 29 additions & 7 deletions scripts/generate-extension-manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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'."
)


Expand Down Expand Up @@ -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)
Expand Down
39 changes: 34 additions & 5 deletions scripts/test_generate_extension_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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__":
Expand Down
Loading