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
11 changes: 11 additions & 0 deletions src/vpcopilot/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,24 @@ def normalize_service_policy_spec(spec: dict) -> dict:
return spec


def artifact_spec(spec: dict) -> dict:
"""Unwrap a generated artifact to the bare XC spec.

Generated artifacts vary: some are a full create body `{metadata, spec}`, some a bare spec —
`apply_from_scan` already handles both. The linter has to agree, or a perfectly good policy is
reported as "no DENY rule" purely because `rule_list` sits one level further down."""
inner = spec.get("spec") if isinstance(spec, dict) else None
return inner if isinstance(inner, dict) and inner else spec


def lint_service_policy(spec: dict, exploit: dict | None) -> list[str]:
"""Deterministic pre-apply lint — catch a service_policy that won't actually block the exploit,
BEFORE any live LB round-trip. Under FIRST_MATCH the FIRST rule whose path+method match the
exploit decides its fate; if that's an ALLOW (a bad rule order, or a DENY whose path is a wrong
guess so an allow-all catches the exploit first), the exploit sails through. Returns issue
strings ([] = looks correct)."""
import re
spec = artifact_spec(spec) # accept a {metadata, spec} artifact, exactly as apply_from_scan does
rules = (spec.get("rule_list") or {}).get("rules") or []
if not any((r.get("spec") or {}).get("action") == "DENY" for r in rules):
return ["no DENY rule"]
Expand Down
31 changes: 31 additions & 0 deletions tests/test_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,34 @@ def test_lint_generated_spec_dispatches_by_control():
assert lint_generated_spec("service_policy", sp, EXPLOIT) == []
assert lint_generated_spec("api_schema", {"paths": {}}, None) # caught
assert lint_generated_spec("rate_limit", {"anything": 1}, None) == [] # parameterized -> advisory


# ---- A3: the linter must read the same shape apply_from_scan consumes ----
def test_lint_unwraps_a_metadata_spec_artifact():
"""generate emits either a bare spec or a full XC create body. apply_from_scan unwraps
{metadata, spec}; when the linter did not, every wrapped policy was reported 'no DENY rule'
even though it had one — a false positive on a perfectly good band-aid."""
inner = _policy([
{"action": "DENY", "path": {"prefix_values": ["/users/v1/register"]}, "http_method": {"methods": ["POST"]}},
{"action": "ALLOW", "path": {"prefix_values": ["/"]}}])
wrapped = {"metadata": {"name": "deny-register"}, "spec": inner}
assert lint_service_policy(wrapped, EXPLOIT) == []
assert lint_generated_spec("service_policy", wrapped, EXPLOIT) == []


def test_lint_still_catches_a_wrapped_policy_with_no_deny():
wrapped = {"metadata": {"name": "x"}, "spec": _policy([{"action": "ALLOW", "path": {"prefix_values": ["/"]}}])}
assert lint_service_policy(wrapped, EXPLOIT) == ["no DENY rule"]


def test_lint_still_catches_a_wrapped_allow_before_deny():
wrapped = {"metadata": {"name": "x"}, "spec": _policy([
{"action": "ALLOW", "path": {"prefix_values": ["/users/v1/register"]}, "http_method": {"methods": ["POST"]}},
{"action": "DENY", "path": {"prefix_values": ["/users/v1/register"]}, "http_method": {"methods": ["POST"]}}])}
assert lint_service_policy(wrapped, EXPLOIT)


def test_lint_leaves_a_bare_spec_alone():
bare = _policy([{"action": "DENY", "path": {"prefix_values": ["/users/v1/register"]},
"http_method": {"methods": ["POST"]}}])
assert lint_service_policy(bare, EXPLOIT) == []
Loading