@nitra/cursor 1.8.221 → 1.8.228

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.
Files changed (44) hide show
  1. package/.claude-template/npm-CLAUDE.md +4 -0
  2. package/CHANGELOG.md +69 -0
  3. package/bin/auto-rules.md +2 -0
  4. package/bin/n-cursor.js +3 -2
  5. package/mdc/abie.mdc +13 -0
  6. package/mdc/ci4.mdc +8 -0
  7. package/mdc/tauri.mdc +20 -0
  8. package/package.json +1 -1
  9. package/policy/abie/base_deployment_preem/base_deployment_preem.rego +56 -0
  10. package/policy/abie/base_deployment_preem/base_deployment_preem_test.rego +60 -0
  11. package/policy/abie/clean_merged_ignore_branches/clean_merged_ignore_branches.rego +100 -0
  12. package/policy/abie/clean_merged_ignore_branches/clean_merged_ignore_branches_test.rego +48 -0
  13. package/policy/abie/health_check_policy/health_check_policy.rego +91 -22
  14. package/policy/abie/health_check_policy/health_check_policy_test.rego +99 -0
  15. package/policy/abie/http_route_base/http_route_base_test.rego +64 -0
  16. package/policy/k8s/base_kustomization/base_kustomization.rego +40 -0
  17. package/policy/k8s/base_kustomization/base_kustomization_test.rego +36 -0
  18. package/policy/k8s/base_manifest/base_manifest.rego +154 -0
  19. package/policy/k8s/base_manifest/base_manifest_test.rego +94 -0
  20. package/policy/k8s/gateway/gateway.rego +151 -0
  21. package/policy/k8s/gateway/gateway_test.rego +122 -0
  22. package/policy/k8s/hasura_configmap/hasura_configmap.rego +69 -0
  23. package/policy/k8s/hasura_configmap/hasura_configmap_test.rego +49 -0
  24. package/policy/k8s/hasura_httproute/hasura_httproute.rego +298 -0
  25. package/policy/k8s/hasura_httproute/hasura_httproute_test.rego +148 -0
  26. package/policy/k8s/hpa_pdb/hpa_pdb.rego +139 -0
  27. package/policy/k8s/hpa_pdb/hpa_pdb_test.rego +101 -0
  28. package/policy/k8s/kustomization/kustomization.rego +220 -0
  29. package/policy/k8s/kustomization/kustomization_test.rego +128 -0
  30. package/policy/k8s/kustomize_managed/kustomize_managed.rego +31 -0
  31. package/policy/k8s/kustomize_managed/kustomize_managed_test.rego +30 -0
  32. package/policy/k8s/manifest/manifest.rego +151 -4
  33. package/policy/k8s/manifest/manifest_test.rego +309 -0
  34. package/policy/k8s/svc_hl_yaml/svc_hl_yaml.rego +51 -0
  35. package/policy/k8s/svc_hl_yaml/svc_hl_yaml_test.rego +42 -0
  36. package/policy/k8s/svc_yaml/svc_yaml.rego +31 -0
  37. package/policy/k8s/svc_yaml/svc_yaml_test.rego +41 -0
  38. package/scripts/check-abie.mjs +102 -369
  39. package/scripts/check-ga.mjs +89 -9
  40. package/scripts/check-k8s.mjs +128 -569
  41. package/scripts/lint-conftest.mjs +98 -3
  42. package/scripts/lint-ga.mjs +18 -132
  43. package/scripts/lint-rego.mjs +19 -4
  44. package/scripts/utils/run-conftest-batch.mjs +117 -0
@@ -0,0 +1,122 @@
1
+ # Тести для `k8s.gateway`. Запуск:
2
+ # conftest verify -p npm/policy/k8s/gateway --namespace k8s.gateway
3
+ package k8s.gateway_test
4
+
5
+ import rego.v1
6
+
7
+ import data.k8s.gateway
8
+
9
+ # ── HealthCheckPolicy ─────────────────────────────────────────────────────
10
+
11
+ test_deny_hcp_targetref_without_hl_suffix if {
12
+ count(gateway.deny) > 0 with input as {
13
+ "apiVersion": "networking.gke.io/v1",
14
+ "kind": "HealthCheckPolicy",
15
+ "metadata": {"name": "hc"},
16
+ "spec": {"targetRef": {
17
+ "group": "",
18
+ "kind": "Service",
19
+ "name": "auth",
20
+ }},
21
+ }
22
+ }
23
+
24
+ test_allow_hcp_targetref_with_hl_suffix if {
25
+ count(gateway.deny) == 0 with input as {
26
+ "apiVersion": "networking.gke.io/v1",
27
+ "kind": "HealthCheckPolicy",
28
+ "metadata": {"name": "hc"},
29
+ "spec": {"targetRef": {
30
+ "group": "",
31
+ "kind": "Service",
32
+ "name": "auth-hl",
33
+ }},
34
+ }
35
+ }
36
+
37
+ test_deny_hcp_missing_targetref if {
38
+ count(gateway.deny) > 0 with input as {
39
+ "apiVersion": "networking.gke.io/v1",
40
+ "kind": "HealthCheckPolicy",
41
+ "metadata": {"name": "hc"},
42
+ "spec": {},
43
+ }
44
+ }
45
+
46
+ # Без kind=Service у targetRef правило не діє (інші kind не оцінюємо).
47
+ test_allow_hcp_targetref_other_kind if {
48
+ count(gateway.deny) == 0 with input as {
49
+ "apiVersion": "networking.gke.io/v1",
50
+ "kind": "HealthCheckPolicy",
51
+ "metadata": {"name": "hc"},
52
+ "spec": {"targetRef": {
53
+ "kind": "Gateway",
54
+ "name": "gw",
55
+ }},
56
+ }
57
+ }
58
+
59
+ # ── HTTPRoute backendRef → Service з суфіксом `-hl` ──────────────────────
60
+
61
+ test_deny_httproute_backend_without_hl if {
62
+ count(gateway.deny) > 0 with input as {
63
+ "apiVersion": "gateway.networking.k8s.io/v1",
64
+ "kind": "HTTPRoute",
65
+ "metadata": {"name": "r", "namespace": "dev"},
66
+ "spec": {"rules": [{"backendRefs": [{"name": "auth", "port": 8080}]}]},
67
+ }
68
+ }
69
+
70
+ test_allow_httproute_backend_with_hl if {
71
+ count(gateway.deny) == 0 with input as {
72
+ "apiVersion": "gateway.networking.k8s.io/v1",
73
+ "kind": "HTTPRoute",
74
+ "metadata": {"name": "r", "namespace": "dev"},
75
+ "spec": {"rules": [{"backendRefs": [{"name": "auth-hl", "port": 8080}]}]},
76
+ }
77
+ }
78
+
79
+ # ── HTTPRoute backendRef з redundant namespace ───────────────────────────
80
+
81
+ test_deny_httproute_backend_redundant_namespace if {
82
+ count(gateway.deny) > 0 with input as {
83
+ "apiVersion": "gateway.networking.k8s.io/v1",
84
+ "kind": "HTTPRoute",
85
+ "metadata": {"name": "r", "namespace": "dev"},
86
+ "spec": {"rules": [{"backendRefs": [{
87
+ "name": "auth-hl",
88
+ "namespace": "dev",
89
+ "port": 8080,
90
+ }]}]},
91
+ }
92
+ }
93
+
94
+ test_allow_httproute_backend_different_namespace if {
95
+ count(gateway.deny) == 0 with input as {
96
+ "apiVersion": "gateway.networking.k8s.io/v1",
97
+ "kind": "HTTPRoute",
98
+ "metadata": {"name": "r", "namespace": "dev"},
99
+ "spec": {"rules": [{"backendRefs": [{
100
+ "name": "auth-hl",
101
+ "namespace": "other",
102
+ "port": 8080,
103
+ }]}]},
104
+ }
105
+ }
106
+
107
+ # Перевірка не діє на HTTPHeaderMatch (немає `port`).
108
+ test_allow_httproute_header_match_without_port if {
109
+ count(gateway.deny) == 0 with input as {
110
+ "apiVersion": "gateway.networking.k8s.io/v1",
111
+ "kind": "HTTPRoute",
112
+ "metadata": {"name": "r", "namespace": "dev"},
113
+ "spec": {"rules": [{
114
+ "matches": [{"headers": [{
115
+ "type": "Exact",
116
+ "name": "X-Tenant",
117
+ "value": "acme",
118
+ }]}],
119
+ "backendRefs": [{"name": "auth-hl", "port": 8080}],
120
+ }]},
121
+ }
122
+ }
@@ -0,0 +1,69 @@
1
+ # Порт перевірки ConfigMap для Hasura-Deployment з
2
+ # `npm/scripts/check-k8s.mjs` (k8s.mdc): у ConfigMap, що сусідствує з
3
+ # Hasura-Deployment, у `data` обов'язково має бути ключ
4
+ # `HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS` зі значенням `"true"`.
5
+ #
6
+ # Запуск (локально, лише для ConfigMap у каталозі з Hasura-Deployment):
7
+ # conftest test path/to/k8s/.../configmap.yaml \
8
+ # -p npm/policy/k8s/hasura_configmap \
9
+ # --namespace k8s.hasura_configmap
10
+ #
11
+ # Прив'язка ConfigMap-Deployment cross-file — у JS (`check-k8s.mjs`:
12
+ # `validateHasuraConfigMapRemoteSchemaPermissions` шукає Hasura-Deployment
13
+ # у тому ж dir-у і викликає conftest з цією намеспейс лише для відповідних
14
+ # ConfigMap-ів). JS authoritative (`hasuraConfigMapRemoteSchemaPermissionsViolation`,
15
+ # константа `HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY`).
16
+ #
17
+ # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
18
+ # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`.
19
+ package k8s.hasura_configmap
20
+
21
+ import rego.v1
22
+
23
+ # Обов'язковий ключ у `data` (узгоджено з `check-k8s.mjs`).
24
+ required_key := "HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS"
25
+
26
+ key_missing_template := concat(" ", [
27
+ "data.%s: додай ключ зі значенням \"true\"",
28
+ "(Deployment з hasura/graphql-engine — k8s.mdc)",
29
+ ])
30
+
31
+ key_value_wrong_template := concat(" ", ["data.%s: значення має бути \"true\" (зараз: %v) (k8s.mdc)"])
32
+
33
+ deny contains msg if {
34
+ input.kind == "ConfigMap"
35
+ not is_object(object.get(input, "data", null))
36
+ msg := sprintf(key_missing_template, [required_key])
37
+ }
38
+
39
+ deny contains msg if {
40
+ input.kind == "ConfigMap"
41
+ d := object.get(input, "data", null)
42
+ is_object(d)
43
+ not key_present(d)
44
+ msg := sprintf(key_missing_template, [required_key])
45
+ }
46
+
47
+ deny contains msg if {
48
+ input.kind == "ConfigMap"
49
+ d := object.get(input, "data", null)
50
+ is_object(d)
51
+ key_present(d)
52
+ value := d[required_key]
53
+ not is_value_true(value)
54
+ msg := sprintf(key_value_wrong_template, [required_key, value])
55
+ }
56
+
57
+ key_present(d) if {
58
+ required_key in object.keys(d)
59
+ }
60
+
61
+ # Значення вважається "true", якщо це boolean true або рядок "true"
62
+ # (case-insensitive). ConfigMap у Kubernetes тримає рядки, але YAML без лапок
63
+ # дає boolean — приймаємо обидва варіанти.
64
+ is_value_true(true)
65
+
66
+ is_value_true(v) if {
67
+ is_string(v)
68
+ lower(trim_space(v)) == "true"
69
+ }
@@ -0,0 +1,49 @@
1
+ # Тести для `k8s.hasura_configmap`. Запуск:
2
+ # conftest verify -p npm/policy/k8s/hasura_configmap --namespace k8s.hasura_configmap
3
+ package k8s.hasura_configmap_test
4
+
5
+ import rego.v1
6
+
7
+ import data.k8s.hasura_configmap
8
+
9
+ required_key := "HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS"
10
+
11
+ base_cm := {
12
+ "apiVersion": "v1",
13
+ "kind": "ConfigMap",
14
+ "metadata": {"name": "db-h", "namespace": "dev"},
15
+ }
16
+
17
+ with_data(value) := object.union(base_cm, {"data": {required_key: value}})
18
+
19
+ test_deny_missing_data if {
20
+ count(hasura_configmap.deny) > 0 with input as base_cm
21
+ }
22
+
23
+ test_deny_missing_required_key if {
24
+ count(hasura_configmap.deny) > 0 with input as object.union(base_cm, {"data": {"OTHER": "foo"}})
25
+ }
26
+
27
+ test_deny_required_key_value_false if {
28
+ count(hasura_configmap.deny) > 0 with input as with_data("false")
29
+ }
30
+
31
+ test_allow_required_key_string_true if {
32
+ count(hasura_configmap.deny) == 0 with input as with_data("true")
33
+ }
34
+
35
+ test_allow_required_key_boolean_true if {
36
+ count(hasura_configmap.deny) == 0 with input as with_data(true)
37
+ }
38
+
39
+ test_allow_required_key_uppercase_true if {
40
+ count(hasura_configmap.deny) == 0 with input as with_data("TRUE")
41
+ }
42
+
43
+ test_allow_non_configmap if {
44
+ count(hasura_configmap.deny) == 0 with input as {
45
+ "apiVersion": "apps/v1",
46
+ "kind": "Deployment",
47
+ "metadata": {"name": "x"},
48
+ }
49
+ }
@@ -0,0 +1,298 @@
1
+ # Порт перевірки `httpRouteHasuraCanonViolation` з `npm/scripts/check-k8s.mjs`
2
+ # (k8s.mdc): HTTPRoute, що сусідствує з Hasura-Deployment з тим самим
3
+ # `metadata.name`, має містити канон з 4 правил у такому порядку:
4
+ #
5
+ # 1. Exact `<prefix>/ql` → RequestRedirect ReplaceFullPath `<prefix>/ql/console` 302
6
+ # 2. Exact `<prefix>/ql/` → таке саме перенаправлення
7
+ # 3. PathPrefix `<prefix>/ql` → URLRewrite ReplacePrefixMatch `/`, один backendRef
8
+ # 4. WebSocket: PathPrefix `<prefix>/ql` + header `Upgrade: websocket` →
9
+ # URLRewrite ReplacePrefixMatch `/` + RequestHeaderModifier `remove: [Authorization]`,
10
+ # той самий backendRef
11
+ #
12
+ # Додаткові правила поверх канону дозволені — їх просто пропускаємо при пошуку.
13
+ #
14
+ # Запуск (локально, лише для HTTPRoute, парного з Hasura-Deployment):
15
+ # conftest test path/to/k8s/.../hr.yaml -p npm/policy/k8s/hasura_httproute \
16
+ # --namespace k8s.hasura_httproute
17
+ #
18
+ # Прив'язка Deployment-HTTPRoute (cross-file) — у JS (`validateHasuraHttpRouteCanon`,
19
+ # `collectHasuraDeploymentsAndHttpRoutes`); JS викликає conftest з цією
20
+ # намеспейс лише для відповідних HTTPRoute. JS authoritative.
21
+ #
22
+ # Структура каталогу збігається зі шляхом пакету (regal: directory-package-mismatch).
23
+ # Конвенція проєкту — `import rego.v1` + multi-value `deny contains msg if { … }`.
24
+ package k8s.hasura_httproute
25
+
26
+ import rego.v1
27
+
28
+ rule1_missing_msg := concat(" ", [
29
+ "не знайдено правило 1 Hasura-канона:",
30
+ "Exact \"<prefix>/ql\" + RequestRedirect ReplaceFullPath",
31
+ "\"<prefix>/ql/console\" statusCode 302 (k8s.mdc)",
32
+ ])
33
+
34
+ rule1_filters_template := concat(" ", [
35
+ "правило 1 Hasura-канона (rules[%d], prefix %q):",
36
+ "Exact %q має мати RequestRedirect ReplaceFullPath %q statusCode 302 (k8s.mdc)",
37
+ ])
38
+
39
+ rule2_missing_template := concat(" ", [
40
+ "правило 2 Hasura-канона: після правила 1 має бути Exact %q",
41
+ "+ RequestRedirect ReplaceFullPath %q statusCode 302 (k8s.mdc)",
42
+ ])
43
+
44
+ rule3_missing_template := concat(" ", [
45
+ "правило 3 Hasura-канона: після правила 2 має бути PathPrefix %q",
46
+ "+ URLRewrite ReplacePrefixMatch \"/\"",
47
+ "+ один backendRef на headless Service (k8s.mdc)",
48
+ ])
49
+
50
+ rule4_missing_template := concat(" ", [
51
+ "правило 4 Hasura-канона (WebSocket): після правила 3 має бути PathPrefix %q",
52
+ "+ header \"Upgrade: websocket\" + URLRewrite ReplacePrefixMatch \"/\"",
53
+ "+ RequestHeaderModifier remove [Authorization] + backendRef %q (k8s.mdc)",
54
+ ])
55
+
56
+ # ── deny: structural shortcut перевірки до пошуку правила 1 ──────────────
57
+
58
+ deny contains "HTTPRoute без spec — канон Hasura вимагає 4 правил (k8s.mdc)" if {
59
+ input.kind == "HTTPRoute"
60
+ not is_object(object.get(input, "spec", null))
61
+ }
62
+
63
+ deny contains "spec.rules порожній — канон Hasura вимагає 4 правил у порядку (k8s.mdc)" if {
64
+ input.kind == "HTTPRoute"
65
+ is_object(object.get(input, "spec", null))
66
+ not has_non_empty_rules
67
+ }
68
+
69
+ deny contains rule1_missing_msg if {
70
+ input.kind == "HTTPRoute"
71
+ has_non_empty_rules
72
+ canon_start == null
73
+ }
74
+
75
+ # ── deny: канонічна частина (правила 1-4) ────────────────────────────────
76
+
77
+ deny contains msg if {
78
+ input.kind == "HTTPRoute"
79
+ canon_outcome.stage == "rule1_filters"
80
+ msg := sprintf(rule1_filters_template, [
81
+ canon_outcome.start_index,
82
+ canon_outcome.prefix,
83
+ canon_outcome.ql_path,
84
+ canon_outcome.console_path,
85
+ ])
86
+ }
87
+
88
+ deny contains msg if {
89
+ input.kind == "HTTPRoute"
90
+ canon_outcome.stage == "rule2_missing"
91
+ msg := sprintf(rule2_missing_template, [canon_outcome.ql_slash_path, canon_outcome.console_path])
92
+ }
93
+
94
+ deny contains msg if {
95
+ input.kind == "HTTPRoute"
96
+ canon_outcome.stage == "rule3_missing"
97
+ msg := sprintf(rule3_missing_template, [canon_outcome.ql_path])
98
+ }
99
+
100
+ deny contains msg if {
101
+ input.kind == "HTTPRoute"
102
+ canon_outcome.stage == "rule4_missing"
103
+ msg := sprintf(rule4_missing_template, [canon_outcome.ql_path, canon_outcome.backend_name])
104
+ }
105
+
106
+ # ── helpers ───────────────────────────────────────────────────────────────
107
+
108
+ has_non_empty_rules if {
109
+ rules := object.get(object.get(input, "spec", {}), "rules", [])
110
+ is_array(rules)
111
+ count(rules) > 0
112
+ }
113
+
114
+ # Перше правило з matches=[{path={type: Exact, value: <prefix>/ql}}] (без headers).
115
+ # Повертаємо {prefix, start_index} або null, якщо нема.
116
+ default canon_start := null
117
+
118
+ canon_start := canon_start_candidates[0] if {
119
+ count(canon_start_candidates) > 0
120
+ }
121
+
122
+ canon_start_candidates := [{"prefix": substring(p.value, 0, count(p.value) - 3), "start_index": i} |
123
+ some i, rule in input.spec.rules
124
+ matches := object.get(rule, "matches", [])
125
+ count(matches) == 1
126
+ m := matches[0]
127
+ not m.headers
128
+ p := object.get(m, "path", {})
129
+ p.type == "Exact"
130
+ is_string(p.value)
131
+ endswith(p.value, "/ql")
132
+ ]
133
+
134
+ # Стиснений результат канон-перевірки. Повертає об'єкт з полем `stage`:
135
+ # "ok" | "rule1_filters" | "rule2_missing" | "rule3_missing" | "rule4_missing".
136
+ default canon_outcome := {"stage": "skip"}
137
+
138
+ canon_outcome := outcome if {
139
+ has_non_empty_rules
140
+ canon_start != null
141
+ prefix := canon_start.prefix
142
+ start_index := canon_start.start_index
143
+ console_path := sprintf("%s/ql/console", [prefix])
144
+ ql_slash_path := sprintf("%s/ql/", [prefix])
145
+ ql_path := sprintf("%s/ql", [prefix])
146
+ outcome := evaluate_canon(prefix, start_index, console_path, ql_slash_path, ql_path)
147
+ }
148
+
149
+ evaluate_canon(prefix, start_index, console_path, ql_slash_path, ql_path) := result if {
150
+ not rule_has_exact_redirect(input.spec.rules[start_index], console_path)
151
+ ctx := canon_ctx(prefix, start_index, console_path, ql_slash_path, ql_path)
152
+ result := object.union(ctx, {"stage": "rule1_filters"})
153
+ } else := result if {
154
+ rule_has_exact_redirect(input.spec.rules[start_index], console_path)
155
+ rule2_index(start_index, ql_slash_path, console_path) == -1
156
+ ctx := canon_ctx(prefix, start_index, console_path, ql_slash_path, ql_path)
157
+ result := object.union(ctx, {"stage": "rule2_missing"})
158
+ } else := result if {
159
+ rule_has_exact_redirect(input.spec.rules[start_index], console_path)
160
+ i2 := rule2_index(start_index, ql_slash_path, console_path)
161
+ i2 != -1
162
+ rule3_index(i2, ql_path) == -1
163
+ ctx := canon_ctx(prefix, start_index, console_path, ql_slash_path, ql_path)
164
+ result := object.union(ctx, {"stage": "rule3_missing"})
165
+ } else := result if {
166
+ rule_has_exact_redirect(input.spec.rules[start_index], console_path)
167
+ i2 := rule2_index(start_index, ql_slash_path, console_path)
168
+ i3 := rule3_index(i2, ql_path)
169
+ i3 != -1
170
+ backend_name := single_backend_name(input.spec.rules[i3])
171
+ rule4_index(i3, ql_path, backend_name) == -1
172
+ result := {
173
+ "stage": "rule4_missing",
174
+ "prefix": prefix,
175
+ "ql_path": ql_path,
176
+ "backend_name": backend_name,
177
+ }
178
+ } else := {"stage": "ok"}
179
+
180
+ canon_ctx(prefix, start_index, console_path, ql_slash_path, ql_path) := {
181
+ "prefix": prefix,
182
+ "start_index": start_index,
183
+ "console_path": console_path,
184
+ "ql_slash_path": ql_slash_path,
185
+ "ql_path": ql_path,
186
+ }
187
+
188
+ # Правило: matches=[{path={type, value}}] без headers.
189
+ rule_matches_single_path_no_headers(rule, path_type, path_value) if {
190
+ matches := object.get(rule, "matches", [])
191
+ count(matches) == 1
192
+ m := matches[0]
193
+ not m.headers
194
+ p := object.get(m, "path", {})
195
+ p.type == path_type
196
+ p.value == path_value
197
+ }
198
+
199
+ rule_has_exact_redirect(rule, to_path) if {
200
+ filters := object.get(rule, "filters", [])
201
+ count(filters) == 1
202
+ f := filters[0]
203
+ f.type == "RequestRedirect"
204
+ rr := object.get(f, "requestRedirect", {})
205
+ rr.statusCode == 302
206
+ p := object.get(rr, "path", {})
207
+ p.type == "ReplaceFullPath"
208
+ p.replaceFullPath == to_path
209
+ }
210
+
211
+ # Серед filters є URLRewrite з ReplacePrefixMatch "/".
212
+ filters_include_url_rewrite_to_slash(filters) if {
213
+ some f in filters
214
+ f.type == "URLRewrite"
215
+ rw := object.get(f, "urlRewrite", {})
216
+ p := object.get(rw, "path", {})
217
+ p.type == "ReplacePrefixMatch"
218
+ p.replacePrefixMatch == "/"
219
+ }
220
+
221
+ # Серед filters є RequestHeaderModifier з remove=[Authorization].
222
+ filters_remove_authorization(filters) if {
223
+ some f in filters
224
+ f.type == "RequestHeaderModifier"
225
+ mod := object.get(f, "requestHeaderModifier", {})
226
+ remove := object.get(mod, "remove", [])
227
+ count(remove) == 1
228
+ remove[0] == "Authorization"
229
+ }
230
+
231
+ default single_backend_name(_) := ""
232
+
233
+ single_backend_name(rule) := refs[0].name if {
234
+ refs := object.get(rule, "backendRefs", [])
235
+ count(refs) == 1
236
+ is_string(refs[0].name)
237
+ }
238
+
239
+ # Шукаємо індекс правила 2 (після `from`); -1 якщо немає.
240
+ default rule2_index(_, _, _) := -1
241
+
242
+ rule2_index(from, ql_slash_path, console_path) := indices[0] if {
243
+ indices := [i |
244
+ some i, rule in input.spec.rules
245
+ i > from
246
+ rule_matches_single_path_no_headers(rule, "Exact", ql_slash_path)
247
+ rule_has_exact_redirect(rule, console_path)
248
+ ]
249
+ count(indices) > 0
250
+ }
251
+
252
+ # Шукаємо індекс правила 3 (після `from`); -1 якщо немає.
253
+ default rule3_index(_, _) := -1
254
+
255
+ rule3_index(from, ql_path) := indices[0] if {
256
+ indices := [i |
257
+ some i, rule in input.spec.rules
258
+ i > from
259
+ rule_matches_single_path_no_headers(rule, "PathPrefix", ql_path)
260
+ filters := object.get(rule, "filters", [])
261
+ count(filters) == 1
262
+ filters_include_url_rewrite_to_slash(filters)
263
+ single_backend_name(rule) != ""
264
+ ]
265
+ count(indices) > 0
266
+ }
267
+
268
+ # Шукаємо індекс правила 4 (WebSocket) (після `from`); -1 якщо немає.
269
+ default rule4_index(_, _, _) := -1
270
+
271
+ rule4_index(from, ql_path, backend_name) := indices[0] if {
272
+ indices := [i |
273
+ some i, rule in input.spec.rules
274
+ i > from
275
+ is_websocket_rule(rule, ql_path)
276
+ single_backend_name(rule) == backend_name
277
+ ]
278
+ count(indices) > 0
279
+ }
280
+
281
+ is_websocket_rule(rule, ql_path) if {
282
+ matches := object.get(rule, "matches", [])
283
+ count(matches) == 1
284
+ m := matches[0]
285
+ p := object.get(m, "path", {})
286
+ p.type == "PathPrefix"
287
+ p.value == ql_path
288
+ headers := object.get(m, "headers", [])
289
+ count(headers) == 1
290
+ h := headers[0]
291
+ h.type == "Exact"
292
+ h.name == "Upgrade"
293
+ h.value == "websocket"
294
+ filters := object.get(rule, "filters", [])
295
+ count(filters) == 2
296
+ filters_include_url_rewrite_to_slash(filters)
297
+ filters_remove_authorization(filters)
298
+ }
@@ -0,0 +1,148 @@
1
+ # Тести для `k8s.hasura_httproute`. Запуск:
2
+ # conftest verify -p npm/policy/k8s/hasura_httproute --namespace k8s.hasura_httproute
3
+ package k8s.hasura_httproute_test
4
+
5
+ import rego.v1
6
+
7
+ import data.k8s.hasura_httproute
8
+
9
+ base_route := {
10
+ "apiVersion": "gateway.networking.k8s.io/v1",
11
+ "kind": "HTTPRoute",
12
+ "metadata": {"name": "db-h", "namespace": "dev"},
13
+ }
14
+
15
+ rule1(prefix) := {
16
+ "matches": [{"path": {"type": "Exact", "value": sprintf("%s/ql", [prefix])}}],
17
+ "filters": [{
18
+ "type": "RequestRedirect",
19
+ "requestRedirect": {
20
+ "path": {"type": "ReplaceFullPath", "replaceFullPath": sprintf("%s/ql/console", [prefix])},
21
+ "statusCode": 302,
22
+ },
23
+ }],
24
+ }
25
+
26
+ rule2(prefix) := {
27
+ "matches": [{"path": {"type": "Exact", "value": sprintf("%s/ql/", [prefix])}}],
28
+ "filters": [{
29
+ "type": "RequestRedirect",
30
+ "requestRedirect": {
31
+ "path": {"type": "ReplaceFullPath", "replaceFullPath": sprintf("%s/ql/console", [prefix])},
32
+ "statusCode": 302,
33
+ },
34
+ }],
35
+ }
36
+
37
+ rule3(prefix, backend) := {
38
+ "matches": [{"path": {"type": "PathPrefix", "value": sprintf("%s/ql", [prefix])}}],
39
+ "filters": [{
40
+ "type": "URLRewrite",
41
+ "urlRewrite": {"path": {"type": "ReplacePrefixMatch", "replacePrefixMatch": "/"}},
42
+ }],
43
+ "backendRefs": [{"name": backend, "port": 8080}],
44
+ }
45
+
46
+ rule4(prefix, backend) := {
47
+ "matches": [{
48
+ "path": {"type": "PathPrefix", "value": sprintf("%s/ql", [prefix])},
49
+ "headers": [{"type": "Exact", "name": "Upgrade", "value": "websocket"}],
50
+ }],
51
+ "filters": [
52
+ {
53
+ "type": "URLRewrite",
54
+ "urlRewrite": {"path": {"type": "ReplacePrefixMatch", "replacePrefixMatch": "/"}},
55
+ },
56
+ {
57
+ "type": "RequestHeaderModifier",
58
+ "requestHeaderModifier": {"remove": ["Authorization"]},
59
+ },
60
+ ],
61
+ "backendRefs": [{"name": backend, "port": 8080}],
62
+ }
63
+
64
+ # ── canonical positive case ─────────────────────────────────────────────
65
+
66
+ test_allow_canonical_route_empty_prefix if {
67
+ count(hasura_httproute.deny) == 0 with input as object.union(base_route, {"spec": {"rules": [
68
+ rule1(""),
69
+ rule2(""),
70
+ rule3("", "db-h-hl"),
71
+ rule4("", "db-h-hl"),
72
+ ]}})
73
+ }
74
+
75
+ test_allow_canonical_route_with_prefix if {
76
+ count(hasura_httproute.deny) == 0 with input as object.union(base_route, {"spec": {"rules": [
77
+ rule1("/notify"),
78
+ rule2("/notify"),
79
+ rule3("/notify", "db-h-hl"),
80
+ rule4("/notify", "db-h-hl"),
81
+ ]}})
82
+ }
83
+
84
+ # ── deny-кейси ───────────────────────────────────────────────────────────
85
+
86
+ test_deny_missing_spec if {
87
+ count(hasura_httproute.deny) > 0 with input as base_route
88
+ }
89
+
90
+ test_deny_empty_rules if {
91
+ count(hasura_httproute.deny) > 0 with input as object.union(base_route, {"spec": {"rules": []}})
92
+ }
93
+
94
+ test_deny_missing_rule1 if {
95
+ count(hasura_httproute.deny) > 0 with input as object.union(base_route, {"spec": {"rules": [{
96
+ "matches": [{"path": {"type": "PathPrefix", "value": "/api"}}],
97
+ "backendRefs": [{"name": "api-hl", "port": 8080}],
98
+ }]}})
99
+ }
100
+
101
+ test_deny_rule1_wrong_redirect if {
102
+ bad_rule1 := object.union(rule1(""), {"filters": [{
103
+ "type": "RequestRedirect",
104
+ "requestRedirect": {
105
+ "path": {"type": "ReplaceFullPath", "replaceFullPath": "/wrong"},
106
+ "statusCode": 302,
107
+ },
108
+ }]})
109
+ count(hasura_httproute.deny) > 0 with input as object.union(base_route, {"spec": {"rules": [
110
+ bad_rule1,
111
+ rule2(""),
112
+ rule3("", "db-h-hl"),
113
+ rule4("", "db-h-hl"),
114
+ ]}})
115
+ }
116
+
117
+ test_deny_rule2_missing if {
118
+ count(hasura_httproute.deny) > 0 with input as object.union(base_route, {"spec": {"rules": [
119
+ rule1(""),
120
+ rule3("", "db-h-hl"),
121
+ rule4("", "db-h-hl"),
122
+ ]}})
123
+ }
124
+
125
+ test_deny_rule3_missing if {
126
+ count(hasura_httproute.deny) > 0 with input as object.union(base_route, {"spec": {"rules": [
127
+ rule1(""),
128
+ rule2(""),
129
+ rule4("", "db-h-hl"),
130
+ ]}})
131
+ }
132
+
133
+ test_deny_rule4_missing if {
134
+ count(hasura_httproute.deny) > 0 with input as object.union(base_route, {"spec": {"rules": [
135
+ rule1(""),
136
+ rule2(""),
137
+ rule3("", "db-h-hl"),
138
+ ]}})
139
+ }
140
+
141
+ test_deny_rule4_wrong_backend if {
142
+ count(hasura_httproute.deny) > 0 with input as object.union(base_route, {"spec": {"rules": [
143
+ rule1(""),
144
+ rule2(""),
145
+ rule3("", "db-h-hl"),
146
+ rule4("", "other-hl"),
147
+ ]}})
148
+ }