diff --git a/docs/decision-evidence.md b/docs/decision-evidence.md index 2a41ec5c..70f6d15e 100644 --- a/docs/decision-evidence.md +++ b/docs/decision-evidence.md @@ -21,12 +21,18 @@ resource is missing. Callers can validate `evidence.as_dict()` without adding a JSON Schema runtime dependency to EgressWeave. The v1 contract requires every emitted field, including the non-empty -`address_count` total and its IPv4/IPv6 family counts. `allowed_methods` may be -an intentionally empty deny-all set, but any method token must already be an -uppercase normalized token and `CONNECT` is never accepted. These checks +`address_count` total and its IPv4/IPv6 family counts. Its `authority` field is a +bounded canonical lowercase ASCII hostname plus TCP port, matching the runtime +comparison form after IDNA processing. The schema rejects URL syntax, +credentials, paths, IP literals and legacy numeric spellings—including dotted +hexadecimal forms—non-canonical hostname spellings, DNS names beyond the runtime +length ceiling, and ports outside `1..65535` rather than accepting sensitive or +impossible authority shapes that runtime evidence cannot emit. `allowed_methods` +may be an intentionally empty deny-all set, but any method token must already be +an uppercase normalized token and `CONNECT` is never accepted. These checks describe evidence for a decision that was already authorized; the artifact is -subject to purpose limitation and does not authorize a request, path, -credential, tenant, or destination by itself. +subject to purpose limitation and does not authorize a request, path, credential, +tenant, or destination by itself. ## Data minimization diff --git a/src/egressweave/schemas/decision-evidence-v1.schema.json b/src/egressweave/schemas/decision-evidence-v1.schema.json index b0343d51..9c53a887 100644 --- a/src/egressweave/schemas/decision-evidence-v1.schema.json +++ b/src/egressweave/schemas/decision-evidence-v1.schema.json @@ -20,7 +20,9 @@ }, "authority": { "type": "string", - "minLength": 1 + "minLength": 3, + "maxLength": 259, + "pattern": "^(?=.{1,253}:)(?![0-9.]+:)(?!0(?:\\.)*x[a-z0-9.-]*:)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*:(?:[1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" }, "allowed_methods": { "type": "array", diff --git a/tests/test_decision_evidence_authority_schema.py b/tests/test_decision_evidence_authority_schema.py new file mode 100644 index 00000000..b4524513 --- /dev/null +++ b/tests/test_decision_evidence_authority_schema.py @@ -0,0 +1,79 @@ +"""Regressions for the privacy-minimized decision-evidence authority schema.""" + +from __future__ import annotations + +import re + +import egressweave + + +def _authority_schema() -> dict[str, object]: + """Return the versioned authority field schema from the public loader.""" + schema = egressweave.get_decision_evidence_json_schema() + properties = schema["properties"] + assert isinstance(properties, dict) + authority = properties["authority"] + assert isinstance(authority, dict) + return authority + + +def test_authority_schema_matches_runtime_canonical_shape() -> None: + """Accept only bounded lowercase hostname-and-port evidence authorities.""" + authority = _authority_schema() + pattern = authority["pattern"] + + assert isinstance(pattern, str) + assert authority["type"] == "string" + assert authority["minLength"] == 3 + assert authority["maxLength"] == 259 + + for value in ( + "api.example.com:443", + "service-1:80", + "xn--bcher-kva.example:8443", + "a:1", + "api.example.com:65535", + ): + assert re.fullmatch(pattern, value) is not None + + +def test_authority_schema_rejects_non_runtime_and_sensitive_shapes() -> None: + """Keep URL syntax, credentials, paths, literals, and invalid ports out.""" + pattern = _authority_schema()["pattern"] + assert isinstance(pattern, str) + + for value in ( + "https://api.example.com:443/private", + "https://user:secret@example.com/private?token=value", + "api.example.com/private:443", + "api.example.com", + "API.EXAMPLE.COM:443", + "api.example.com.:443", + "api..example.com:443", + "-api.example.com:443", + "api.example.com-:443", + "127.0.0.1:443", + "0x7f000001:443", + "0x7f.0.0.1:443", + "0.x7f:443", + "[2001:db8::1]:443", + "api.example.com:0", + "api.example.com:65536", + "api.example.com:99999", + ): + assert re.fullmatch(pattern, value) is None + + +def test_authority_schema_bounds_hostname_length_independent_of_port_width() -> None: + """Reject evidence hostnames that exceed the runtime DNS-name ceiling.""" + pattern = _authority_schema()["pattern"] + assert isinstance(pattern, str) + + label = "a" * 63 + maximum_hostname = f"{label}.{label}.{label}.{'b' * 61}" + oversized_hostname = f"{label}.{label}.{label}.{'b' * 62}" + + assert len(maximum_hostname) == 253 + assert len(oversized_hostname) == 254 + assert re.fullmatch(pattern, f"{maximum_hostname}:1") is not None + assert re.fullmatch(pattern, f"{oversized_hostname}:1") is None diff --git a/tests/test_decision_evidence_schema_current_main.py b/tests/test_decision_evidence_schema_current_main.py index 1288a184..eb7ad021 100644 --- a/tests/test_decision_evidence_schema_current_main.py +++ b/tests/test_decision_evidence_schema_current_main.py @@ -11,6 +11,18 @@ from egressweave.validation import _make_validated_egress_url _REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_AUTHORITY_SCHEMA = { + "type": "string", + "minLength": 3, + "maxLength": 259, + "pattern": ( + r"^(?=.{1,253}:)(?![0-9.]+:)(?!0(?:\.)*x[a-z0-9.-]*:)" + r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)" + r"(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*:" + r"(?:[1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|" + r"65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" + ), +} _METHOD_ITEM_SCHEMA = { "type": "string", "pattern": "^[!#$%&'*+.^_`|~0-9A-Z-]+$", @@ -58,7 +70,7 @@ def test_packaged_schema_matches_protected_main_runtime_shape() -> None: assert properties["schema_version"] == { "const": egressweave.DECISION_EVIDENCE_SCHEMA_VERSION } - assert properties["authority"] == {"type": "string", "minLength": 1} + assert properties["authority"] == _AUTHORITY_SCHEMA assert properties["allowed_methods"] == { "type": "array", "uniqueItems": True, @@ -137,7 +149,7 @@ def test_schema_loader_returns_detached_data_on_every_call() -> None: second = _load_schema() second_properties = second["properties"] assert isinstance(second_properties, dict) - assert second_properties["authority"] == {"type": "string", "minLength": 1} + assert second_properties["authority"] == _AUTHORITY_SCHEMA def test_schema_is_a_packaged_utf8_json_resource() -> None: