@nitra/cursor 1.13.45 → 1.13.49

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.13.49] - 2026-05-19
8
+
9
+ ### Changed
10
+
11
+ - `lint-k8s`: kubescape тепер сканує **зібраний kustomize-маніфест** через stdin (`kustomize build <dir> | kubescape scan -`) для кожного dir-у з `kustomization.yaml` під `…/k8s` (Kustomize Components — `kind: Component` — пропускаються, вони не білдяться окремо). Це усуває false-positive **C-0260** (`Missing network policy`) у каноні з sibling `components/networkpolicy.yaml` без `metadata.namespace`: сирий dir-скан не виконував kustomize, бачив порожній namespace у NetworkPolicy проти непорожнього у Deployment з `base/`, через що `podSelector` не матчився. Якщо `kustomization.yaml` під коренем `…/k8s` немає — fallback на старий dir-скан. Нова PATH-залежність — `kustomize` (додано крок у GHA-шаблоні `lint-k8s.yml`). Bump `k8s.mdc` `1.36` → `1.37`.
12
+
13
+ ## [1.13.48] - 2026-05-19
14
+
15
+ ### Changed
16
+
17
+ - `k8s.network_policy`: канонічний egress NetworkPolicy більше **не дозволяє** `to.namespaceSelector: {}` без `ports:` (catch-all). У шаблоні `networkpolicy.snippet.yaml`, генераторі `buildNetworkPolicyYaml` і rego-policy `network_policy.rego` тепер in-cluster rule має явний список TCP-портів: `80, 443, 5432, 3306, 1433, 6379, 8080, 4317, 4318`. Додатково: `fix k8s` під час прогону знаходить існуючі `networkpolicy.yaml` з legacy catch-all egress і **перезаписує** їх через `buildNetworkPolicyYaml` (повний rebuild за `metadata.name` + `app`-міткою). JS-валідатор `networkPolicyManifestViolations` не змінюється (порти enforce-ить rego). Bump `k8s.mdc` `1.35` → `1.36`. Спец: [docs/superpowers/specs/2026-05-19-networkpolicy-egress-explicit-ports-design.md](../../docs/superpowers/specs/2026-05-19-networkpolicy-egress-explicit-ports-design.md).
18
+
19
+ ## [1.13.47] - 2026-05-19
20
+
21
+ ### Fixed
22
+
23
+ - `js-bun-db`, `js-bun-redis` rules: додано markdown-посилання на `policy/package_json/template/package.json.deny.json` у канонічних `<id>.mdc` — `findMissingMdcRefs` (викликається з `run-rule.mjs`) падав, бо канонічні `.mdc` не містили `[package.json.deny.json](./policy/package_json/template/package.json.deny.json)`. Bump rule versions: `js-bun-db.mdc` `1.7` → `1.8`, `js-bun-redis.mdc` `1.1` → `1.2`.
24
+
25
+ ## [1.13.46] - 2026-05-19
26
+
27
+ ### Changed
28
+
29
+ - `image-avif` rule: двопрохідний rewrite у `check image-avif` — спочатку pre-scan `.vue`/`.html` на raster-посилання (`VUE_RASTER_IMPORT_RE` + `VUE_RASTER_STATIC_SRC_RE`), і лише якщо є хоча б одне — запускається `npx @nitra/minify-image --avif`, rewrite та cleanup AVIF-сиріт. Якщо raster-посилань нема — вихід `0` без жодного side-effect. Bump `image-avif.mdc` `1.3` → `1.4`.
30
+
7
31
  ## [1.13.45] - 2026-05-19
8
32
 
9
33
  ### Fixed
@@ -15,7 +39,7 @@
15
39
 
16
40
  ### Added
17
41
 
18
- - `abie` rule: новий policy-концерн `abie.package_json_docs` — у кореневому `package.json` `devDependencies` має містити `@nitra/abie-docs` (presence-only, версію не фіксуємо). Реалізація: `npm/rules/abie/policy/package_json_docs/` (target.json + .rego + _test.rego). Bump `abie.mdc` `1.20` → `1.21`.
42
+ - `abie` rule: новий policy-концерн `abie.package_json_docs` — у кореневому `package.json` `devDependencies` має містити `@nitra/abie-docs` (presence-only, версію не фіксуємо). Реалізація: `npm/rules/abie/policy/package_json_docs/` (target.json + .rego + \_test.rego). Bump `abie.mdc` `1.20` → `1.21`.
19
43
  - `efes` rule: перший policy-концерн `efes.package_json_docs` — у кореневому `package.json` `devDependencies` має містити `@nitra/efes-docs` (узгоджено з `graphql.mdc`, де схема береться з `node_modules/@nitra/efes-docs/schema/maya.graphql`). Реалізація: `npm/rules/efes/policy/package_json_docs/`. Bump `efes.mdc` `1.0` → `1.1`.
20
44
 
21
45
  ## [1.13.43] - 2026-05-18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.13.45",
3
+ "version": "1.13.49",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -2,7 +2,7 @@
2
2
  description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
3
3
  globs: "**/package.json,**/src/conn/**"
4
4
  alwaysApply: false
5
- version: '1.7'
5
+ version: '1.8'
6
6
  ---
7
7
 
8
8
  ## Підтримувані версії баз даних
@@ -17,6 +17,8 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
17
17
  - Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
18
18
  - Замінити на `import { sql, SQL } from 'bun'` — Bun має вбудований клієнт із пулом, prepared statements та tagged templates.
19
19
 
20
+ Канон заборонених `dependencies` (`pg`, `pg-format`, `mysql2`): [package.json.deny.json](./policy/package_json/template/package.json.deny.json).
21
+
20
22
  `pg-format` — це ручне форматування SQL через escape (`format('... %L ...', value)`); такі рядки легко поламати неправильним типом, locale-залежним escape або забутим `%L`. Tagged template Bun SQL параметризує значення нативно (`sql\`... ${value} ...\``) і не лишає простору для injection — окремий «форматер» не потрібен.
21
23
 
22
24
  ## `pg-format`: повне видалення, без шимів
@@ -2,7 +2,7 @@
2
2
  description: Використання Redis/Valkey з Bun
3
3
  globs: "**/package.json,**/src/conn/**"
4
4
  alwaysApply: false
5
- version: '1.1'
5
+ version: '1.2'
6
6
  ---
7
7
 
8
8
  ## Підтримувані версії redis
@@ -16,3 +16,5 @@ Redis 7.2+
16
16
  - Видалити з `dependencies`: `ioredis`, `node-redis`.
17
17
  - Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
18
18
  - Замінити на `import { redis } from 'bun'`
19
+
20
+ Канон заборонених `dependencies` (`ioredis`, `node-redis`, `redis`, `@redis/*`): [package.json.deny.json](./policy/package_json/template/package.json.deny.json).
@@ -4252,9 +4252,16 @@ export function pdbManifestViolations(manifest, expectedAppLabel, isDevLike) {
4252
4252
  return errs
4253
4253
  }
4254
4254
 
4255
+ /**
4256
+ * Канонічний список in-cluster TCP-портів у `to: [{namespaceSelector: {}}]` rule (k8s.mdc).
4257
+ * Зовнішній доступ (80/443 → 0.0.0.0/0) і kube-dns (53 UDP/TCP) — окремі rule вище.
4258
+ * Catch-all (`namespaceSelector: {}` без `ports:`) — заборонено.
4259
+ */
4260
+ const NETWORK_POLICY_IN_CLUSTER_DEFAULT_PORTS = [80, 443, 5432, 3306, 1433, 6379, 8080, 4317, 4318]
4261
+
4255
4262
  /**
4256
4263
  * Канонічний блок `spec.egress` NetworkPolicy (k8s.mdc): kube-dns; TCP 80/443 на 0.0.0.0/0;
4257
- * інші порти — `namespaceSelector: {}` (in-cluster, зокрема `*.svc`).
4264
+ * in-cluster `namespaceSelector: {}` зі списком `NETWORK_POLICY_IN_CLUSTER_DEFAULT_PORTS`.
4258
4265
  */
4259
4266
  const NETWORK_POLICY_EGRESS_YAML = ` egress:
4260
4267
  - to:
@@ -4279,6 +4286,8 @@ const NETWORK_POLICY_EGRESS_YAML = ` egress:
4279
4286
  port: 443
4280
4287
  - to:
4281
4288
  - namespaceSelector: {}
4289
+ ports:
4290
+ ${NETWORK_POLICY_IN_CLUSTER_DEFAULT_PORTS.map(p => ` - protocol: TCP\n port: ${p}`).join('\n')}
4282
4291
  `
4283
4292
 
4284
4293
  /**
@@ -6406,6 +6415,76 @@ async function appendNetworkPolicyDocuments(npAbs, toAdd, npRel, passFn) {
6406
6415
  }
6407
6416
  }
6408
6417
 
6418
+ /**
6419
+ * Перевіряє, чи `spec.egress` містить in-cluster rule з порожнім namespaceSelector БЕЗ ports
6420
+ * (legacy catch-all — заборонено новим каноном k8s.mdc).
6421
+ * @param {unknown} doc розпарсений NetworkPolicy-документ
6422
+ * @returns {boolean} true якщо doc має legacy catch-all rule
6423
+ */
6424
+ function networkPolicyHasLegacyCatchAllEgress(doc) {
6425
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return false
6426
+ const spec = /** @type {Record<string, unknown>} */ (doc).spec
6427
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
6428
+ const egress = /** @type {Record<string, unknown>} */ (spec).egress
6429
+ if (!Array.isArray(egress)) return false
6430
+ for (const rule of egress) {
6431
+ if (rule === null || typeof rule !== 'object' || Array.isArray(rule)) continue
6432
+ const ruleRec = /** @type {Record<string, unknown>} */ (rule)
6433
+ const to = ruleRec.to
6434
+ if (!Array.isArray(to)) continue
6435
+ const hasEmptyNsPeer = to.some(peer => {
6436
+ if (peer === null || typeof peer !== 'object' || Array.isArray(peer)) return false
6437
+ const ns = /** @type {Record<string, unknown>} */ (peer).namespaceSelector
6438
+ return ns !== null && typeof ns === 'object' && !Array.isArray(ns) && Object.keys(ns).length === 0
6439
+ })
6440
+ if (!hasEmptyNsPeer) continue
6441
+ const ports = ruleRec.ports
6442
+ if (!Array.isArray(ports) || ports.length === 0) return true
6443
+ }
6444
+ return false
6445
+ }
6446
+
6447
+ /**
6448
+ * Migrate legacy `networkpolicy.yaml`: якщо хоч один документ має catch-all in-cluster egress —
6449
+ * перезаписати **всі** документи у файлі через `buildNetworkPolicyYaml(name, appLabel)`. Деталі — k8s.mdc.
6450
+ * @param {string} npAbs абсолютний шлях до networkpolicy.yaml
6451
+ * @returns {Promise<boolean>} true якщо файл переписаний
6452
+ */
6453
+ export async function regenerateLegacyNetworkPolicyDocsInFile(npAbs) {
6454
+ if (!existsSync(npAbs)) return false
6455
+ const docs = await readAllDocsByKindFromFile(npAbs, 'NetworkPolicy')
6456
+ if (docs.length === 0) return false
6457
+ const needsMigration = docs.some(d => networkPolicyHasLegacyCatchAllEgress(d))
6458
+ if (!needsMigration) return false
6459
+ /**
6460
+ @type {Array<{ name: string, appLabel: string }>}
6461
+ */
6462
+ const specs = []
6463
+ for (const doc of docs) {
6464
+ const name = manifestMetadataName(doc)
6465
+ const spec = /** @type {Record<string, unknown>} */ (doc).spec
6466
+ let appLabel = ''
6467
+ if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) {
6468
+ const podSelector = /** @type {Record<string, unknown>} */ (spec).podSelector
6469
+ if (podSelector !== null && typeof podSelector === 'object' && !Array.isArray(podSelector)) {
6470
+ const matchLabels = /** @type {Record<string, unknown>} */ (podSelector).matchLabels
6471
+ if (matchLabels !== null && typeof matchLabels === 'object' && !Array.isArray(matchLabels)) {
6472
+ const a = /** @type {Record<string, unknown>} */ (matchLabels).app
6473
+ if (typeof a === 'string') appLabel = a
6474
+ }
6475
+ }
6476
+ }
6477
+ if (typeof name === 'string' && name !== '' && appLabel !== '') specs.push({ name, appLabel })
6478
+ }
6479
+ if (specs.length === 0) return false
6480
+ const blocks = specs.map(({ name, appLabel }, i) => {
6481
+ const block = buildNetworkPolicyYaml(name, appLabel)
6482
+ return i === 0 ? block.trimEnd() : stripYamlLanguageServerModeline(block).trimEnd()
6483
+ })
6484
+ await writeFile(npAbs, `${blocks.join('\n---\n')}\n`, 'utf8')
6485
+ return true
6486
+ }
6487
+
6409
6488
  /**
6410
6489
  * Створює відсутні NetworkPolicy для workload-ів у каталозі (base → `components/`, інакше — поруч).
6411
6490
  * @param {string} dir абсолютний каталог workload-маніфесту
@@ -6420,6 +6499,12 @@ async function ensureNetworkPoliciesForWorkloadsInDir(dir, workloads, rootNorm,
6420
6499
  const isBase = isK8sYamlUnderBaseDirectory(`${relDir}/probe.yaml`)
6421
6500
  const npAbs = isBase ? join(dir, '..', COMPONENTS_DIR, NETWORK_POLICY_FILENAME) : join(dir, NETWORK_POLICY_FILENAME)
6422
6501
  const npRel = (relative(rootNorm, npAbs) || npAbs).replaceAll('\\', '/')
6502
+ if (existsSync(npAbs)) {
6503
+ const migrated = await regenerateLegacyNetworkPolicyDocsInFile(npAbs)
6504
+ if (migrated) {
6505
+ passFn(`${npRel}: міграція legacy catch-all egress → канон з явними in-cluster портами (k8s.mdc)`)
6506
+ }
6507
+ }
6423
6508
  const existing = await existingNetworkPolicyNames(npAbs)
6424
6509
  /**
6425
6510
  @type {Array<{ name: string, appLabel: string, kind: string }>}
package/rules/k8s/k8s.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
3
- version: '1.35'
3
+ version: '1.37'
4
4
  globs: "**/k8s/**/*.yaml"
5
5
  alwaysApply: false
6
6
  ---
@@ -29,11 +29,11 @@ alwaysApply: false
29
29
 
30
30
  Окремо від modeline `$schema` у редакторі варто ганяти CLI-лінтери (**kubeconform** і **kubescape**) по тих самих дерев’ях **`…/k8s`**.
31
31
 
32
- **Залежності:** виконувані файли kubeconform і kubescape у **PATH**; не додавай їх у **devDependencies**.
32
+ **Залежності:** виконувані файли kubeconform, kubescape і kustomize у **PATH**; не додавай їх у **devDependencies**.
33
33
 
34
34
  **Версія Kubernetes для kubeconform** має відповідати PIN yannh у цьому правилі та в **`check-k8s.mjs`** (зараз **`-kubernetes-version 1.33.9`** — semver без префікса `v`, еквівалент релізу **v1.33.9**; набір схем **`v1.33.9-standalone-strict`**). Для CRD додатково підключай реєстр [datreeio/CRDs-catalog](https://github.com/datreeio/CRDs-catalog) другим **`-schema-location`**, як у [прикладах kubeconform](https://github.com/yannh/kubeconform#readme). За потреби **`-ignore-missing-schemas`**, якщо частина CRD ще без публічної схеми.
35
35
 
36
- **kubescape:** типово **`kubescape scan <каталог-k8s>`**; поріг серйозності підлаштуй під проєкт (наприклад **`--severity-threshold high`**). Перший запуск може завантажувати артефакти — у CI потрібна мережа або [offline](https://github.com/kubescape/kubescape#readme). На відміну від kubeconform, у **kubescape scan** немає прапорця **`-kubernetes-version`**: перевірка йде за **framework/control** (NSA, MITRE, CIS тощо), а не проти OpenAPI-схеми конкретного релізу Kubernetes. **Орієнтир** для репозиторію той самий, що й для kubeconform — кластер **v1.33.9** (див. **`-kubernetes-version 1.33.9`** вище); для CIS і подібних наближень обирай актуальний framework під політику команди (**`kubescape list frameworks`**, див. [CLI reference](https://github.com/kubescape/kubescape/blob/master/docs/cli-reference.md)).
36
+ **kubescape вхід через зібраний kustomize-маніфест:** для кожного dir-у з `kustomization.yaml` (`kind: Kustomization`; **`kind: Component`** пропускається — він не білдиться окремо) `lint-k8s` виконує **`kustomize build <dir> | kubescape scan -`** з порогом **`--severity-threshold high`**. Це усуває false-positive **C-0260** (`Missing network policy`) у каноні з sibling **`components/networkpolicy.yaml`** без `metadata.namespace`: сирий dir-скан не виконує kustomize-збірку й бачить порожній namespace у NetworkPolicy проти непорожнього у Deployment з `base/`, тож `podSelector` не матчиться. Якщо в дереві **`…/k8s`** немає жодного `kustomization.yaml` (проєкт без Kustomize) — fallback на старий dir-скан **`kubescape scan <каталог-k8s>`**. Перший запуск kubescape може завантажувати артефакти — у CI потрібна мережа або [offline](https://github.com/kubescape/kubescape#readme). На відміну від kubeconform, у **kubescape scan** немає прапорця **`-kubernetes-version`**: перевірка йде за **framework/control** (NSA, MITRE, CIS тощо), а не проти OpenAPI-схеми конкретного релізу Kubernetes. **Орієнтир** для репозиторію той самий, що й для kubeconform — кластер **v1.33.9** (див. **`-kubernetes-version 1.33.9`** вище); для CIS і подібних наближень обирай актуальний framework під політику команди (**`kubescape list frameworks`**, див. [CLI reference](https://github.com/kubescape/kubescape/blob/master/docs/cli-reference.md)).
37
37
 
38
38
  ### Винятки kubescape: `.kubescape-exceptions.json`
39
39
 
@@ -99,6 +99,11 @@ jobs:
99
99
  curl -s https://raw.githubusercontent.com/kubescape/kubescape/master/install.sh | /bin/bash
100
100
  echo "$HOME/.kubescape/bin" >> $GITHUB_PATH
101
101
 
102
+ - name: Install kustomize
103
+ run: |
104
+ curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
105
+ sudo mv kustomize /usr/local/bin/
106
+
102
107
  - name: Lint K8s
103
108
  run: bun run lint-k8s
104
109
  ```
@@ -388,7 +393,7 @@ images:
388
393
 
389
394
  - **`kustomization.yaml`** — `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component`, `resources: [hpa.yaml, networkpolicy.yaml, pdb.yaml]` (відсортовано за алфавітом).
390
395
  - **`hpa.yaml`** — `autoscaling/v2`, `HorizontalPodAutoscaler`, **без** `metadata.namespace` (namespace задає kustomization-споживач), `spec.scaleTargetRef.name` **= `metadata.name`** Deployment з base, dev-like значення `minReplicas: 1`, `maxReplicas: 1`.
391
- - **`networkpolicy.yaml`** — `networking.k8s.io/v1`, `NetworkPolicy`, **без** `metadata.namespace`; один або кілька документів (`---`) — по одному на кожен workload (**Deployment** / **StatefulSet** / **DaemonSet** / **Job** / **CronJob**) у sibling `base/`; `metadata.name` **= `metadata.name`** workload, `spec.podSelector.matchLabels.app` **= мітка `app`** workload. **Ingress:** `from.podSelector: {}` (інші Pod у namespace). **Egress (усі workload-и):** kube-dns (UDP/TCP 53); **TCP 80 і 443** на `0.0.0.0/0` (HTTP/HTTPS назовні, включно з metadata `169.254.169.254:80`); **інші порти** — лише in-cluster через `to.namespaceSelector: {}` (трафік на `*.svc` / Pod-и в кластері; Postgres лише `*.svc`, без Cloud SQL). Заборонено `egress: [{}]`. Канон: [networkpolicy.snippet.yaml](./policy/network_policy/template/networkpolicy.snippet.yaml). Якщо документа для workload немає — **`check k8s`** дописує його автоматично.
396
+ - **`networkpolicy.yaml`** — `networking.k8s.io/v1`, `NetworkPolicy`, **без** `metadata.namespace`; один або кілька документів (`---`) — по одному на кожен workload (**Deployment** / **StatefulSet** / **DaemonSet** / **Job** / **CronJob**) у sibling `base/`; `metadata.name` **= `metadata.name`** workload, `spec.podSelector.matchLabels.app` **= мітка `app`** workload. **Ingress:** `from.podSelector: {}` (інші Pod у namespace). **Egress (усі workload-и):** kube-dns (UDP/TCP 53); **TCP 80 і 443** на `0.0.0.0/0` (HTTP/HTTPS назовні, включно з metadata `169.254.169.254:80`); **in-cluster** `to.namespaceSelector: {}` з **явним списком TCP-портів** (`80, 443, 5432, 3306, 1433, 6379, 8080, 4317, 4318`; трафік на `*.svc` / Pod-и в кластері). Заборонено: `egress: [{}]`; `to.namespaceSelector: {}` без `ports:` (catch-all). Додаткові in-cluster порти можна додати вручну у `ports:` цього rule. Канон: [networkpolicy.snippet.yaml](./policy/network_policy/template/networkpolicy.snippet.yaml). Якщо документа для workload немає — **`check k8s`** дописує його автоматично.
392
397
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, **без** `metadata.namespace`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment, dev-like `minAvailable: 0`.
393
398
 
394
399
  Інші назви каталогу (`scale/`, `hpa-component/`, `pdb-component/`) — fail.
@@ -509,6 +514,25 @@ spec:
509
514
  port: 443
510
515
  - to:
511
516
  - namespaceSelector: {}
517
+ ports:
518
+ - protocol: TCP
519
+ port: 80
520
+ - protocol: TCP
521
+ port: 443
522
+ - protocol: TCP
523
+ port: 5432
524
+ - protocol: TCP
525
+ port: 3306
526
+ - protocol: TCP
527
+ port: 1433
528
+ - protocol: TCP
529
+ port: 6379
530
+ - protocol: TCP
531
+ port: 8080
532
+ - protocol: TCP
533
+ port: 4317
534
+ - protocol: TCP
535
+ port: 4318
512
536
  ```
513
537
 
514
538
  ```yaml title="k8s/components/hpa.yaml"
@@ -14,8 +14,11 @@
14
14
  */
15
15
  import { spawnSync } from 'node:child_process'
16
16
  import { existsSync } from 'node:fs'
17
+ import { readFile } from 'node:fs/promises'
17
18
  import { basename, dirname, join, relative } from 'node:path'
18
19
 
20
+ import { parse } from 'yaml'
21
+
19
22
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
20
23
  import { loadCursorIgnorePaths } from '../../../scripts/utils/load-cursor-config.mjs'
21
24
  import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
@@ -24,6 +27,9 @@ import { walkDir } from '../../../scripts/utils/walkDir.mjs'
24
27
  /** Per-project kubescape exceptions file; підмішується через --exceptions, якщо існує в корені. */
25
28
  const KUBESCAPE_EXCEPTIONS_FILE = '.kubescape-exceptions.json'
26
29
 
30
+ /** Назва kustomization-файлу (під `k8s` дозволено лише `.yaml`, див. k8s.mdc). */
31
+ const KUSTOMIZATION_FILE = 'kustomization.yaml'
32
+
27
33
  const PATH_SEPARATOR_RE = /[/\\]/u
28
34
  const YAML_EXT_RE = /\.yaml$/iu
29
35
 
@@ -134,37 +140,136 @@ export function buildKubescapeExceptionsArgs(root) {
134
140
  }
135
141
 
136
142
  /**
137
- * Запускає kubescape scan для кожного каталогу окремо (узгоджено з прикладами CLI).
138
- * Немає прапорця версії Kubernetes за потреби додай `scan framework <ім’я>` під CIS/інші набори.
143
+ * Знаходить каталоги-«точки входу» Kustomize під `dir` ті, що містять `kustomization.yaml`,
144
+ * чий перший YAML-документ має `kind: Kustomization` (або без `kind` типово Kustomization).
145
+ * Kustomize Components (`kind: Component`) пропускаються: вони не білдяться окремо,
146
+ * а підключаються через `components:` із overlay (див. k8s.mdc, секція «Kustomize: структура каталогів»).
139
147
  *
140
- * Якщо в корені проєкту є `.kubescape-exceptions.json` підмішується через `--exceptions <file>`.
141
- * Файл потрібен для точкових винятків control'ів kubescape (напр. C-0012 на ConfigMap, що містить
142
- * публічний JWT-конфіг типу `HASURA_GRAPHQL_JWT_SECRET={"jwk_url": "https://…"}` control тригериться
143
- * на ім'я env, а не на значення; див. приклад у `k8s.mdc`).
148
+ * Семантика збігається з реальною поведінкою `kustomize build <dir>`: воно зчитує саме
149
+ * `kustomization.yaml`, тож пошук іде за назвою файлу (без `.yml`-варіанту заборонено каноном).
150
+ * @param {string} dir абсолютний шлях до `…/k8s`
151
+ * @returns {Promise<string[]>} відсортовані абсолютні шляхи до dir-ів з білдабельним `kustomization.yaml`
152
+ */
153
+ export async function findKustomizationDirs(dir) {
154
+ /** @type {string[]} */
155
+ const candidates = []
156
+ await walkDir(dir, p => {
157
+ if (basename(p) === KUSTOMIZATION_FILE) candidates.push(p)
158
+ })
159
+ /** @type {Set<string>} */
160
+ const result = new Set()
161
+ for (const p of candidates) {
162
+ let text
163
+ try {
164
+ text = await readFile(p, 'utf8')
165
+ } catch {
166
+ continue
167
+ }
168
+ let doc
169
+ try {
170
+ doc = parse(text)
171
+ } catch {
172
+ continue
173
+ }
174
+ if (doc && typeof doc === 'object' && doc.kind === 'Component') continue
175
+ result.add(dirname(p))
176
+ }
177
+ return [...result].toSorted((a, b) => a.localeCompare(b))
178
+ }
179
+
180
+ /**
181
+ * Запускає `kustomize build <dir>` і повертає stdout як буфер. stderr інхеритимо в термінал,
182
+ * щоб помилки збірки (биті посилання, недозволені плагіни) було видно одразу.
183
+ * @param {string} kustomizePath абсолютний шлях до бінарника kustomize
184
+ * @param {string} dir абсолютний шлях до каталогу з `kustomization.yaml`
185
+ * @returns {{ status: number, stdout: Buffer }} статус процесу і зібраний маніфест
186
+ */
187
+ function runKustomizeBuild(kustomizePath, dir) {
188
+ const r = spawnSync(kustomizePath, ['build', dir], {
189
+ stdio: ['ignore', 'pipe', 'inherit'],
190
+ shell: false
191
+ })
192
+ return { status: r.status ?? 1, stdout: r.stdout ?? Buffer.alloc(0) }
193
+ }
194
+
195
+ /**
196
+ * Запускає `kubescape scan -` зі stdin (буфер `manifest`). stdout/stderr інхерит у термінал.
197
+ * @param {string} kubescapePath абсолютний шлях до бінарника kubescape
198
+ * @param {Buffer} manifest зібраний kustomize-маніфест
199
+ * @param {string[]} exceptionsArgs `['--exceptions', '<file>']` або `[]`
200
+ * @returns {{ status: number, enoent: boolean }} статус процесу і прапор ENOENT
201
+ */
202
+ function runKubescapeStdin(kubescapePath, manifest, exceptionsArgs) {
203
+ const r = spawnSync(kubescapePath, ['scan', '-', '--severity-threshold', 'high', ...exceptionsArgs], {
204
+ input: manifest,
205
+ stdio: ['pipe', 'inherit', 'inherit'],
206
+ shell: false
207
+ })
208
+ const enoent = Boolean(r.error && 'code' in r.error && r.error.code === 'ENOENT')
209
+ return { status: r.status ?? 1, enoent }
210
+ }
211
+
212
+ /**
213
+ * Запускає kubescape по зібраному kustomize-маніфесту для кожного `…/k8s`-кореня. Для кожного
214
+ * dir-у з `kustomization.yaml` (крім `kind: Component`) робимо `kustomize build <dir>` і піпимо
215
+ * stdout у `kubescape scan -`. Це усуває false-positive C-0260 (`Missing network policy`) у випадках,
216
+ * коли NetworkPolicy живе у sibling `components/` без `metadata.namespace` (намспейс інжектить
217
+ * overlay через `kustomization.namespace`); сирий dir-скан не виконує kustomize й бачить порожній
218
+ * `namespace` у NetworkPolicy проти непорожнього у Deployment, через що `podSelector` не матчиться.
219
+ *
220
+ * Якщо в `…/k8s`-корені немає жодного білдабельного kustomization.yaml (проєкт без Kustomize) —
221
+ * fallback на старий dir-скан, щоб не блокувати чистий YAML-only набір маніфестів.
222
+ *
223
+ * Якщо в корені репо є `.kubescape-exceptions.json` — підмішується через `--exceptions <file>`
224
+ * (точкові винятки control'ів, напр. C-0012 на ConfigMap з публічним JWT-конфігом; див. k8s.mdc).
144
225
  * @param {string[]} dirs абсолютні шляхи до `…/k8s`
145
226
  * @param {string} root корінь репозиторію (для пошуку exceptions-файлу)
146
- * @returns {number} 0 при успіху, інакше код останнього невдалого scan або 127, якщо kubescape відсутній у PATH
227
+ * @returns {Promise<number>} 0 при успіху, інакше код невдалого процесу або 127, якщо kubescape/kustomize відсутні
147
228
  */
148
- function runKubescape(dirs, root) {
229
+ async function runKubescape(dirs, root) {
149
230
  const exceptionsArgs = buildKubescapeExceptionsArgs(root)
150
231
  if (exceptionsArgs.length > 0) {
151
232
  console.log(`run-k8s: kubescape exceptions — ${KUBESCAPE_EXCEPTIONS_FILE}`)
152
233
  }
234
+ const kubescapePath = resolveCmd('kubescape')
235
+ if (!kubescapePath) {
236
+ console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
237
+ return 127
238
+ }
239
+ let kustomizePath = null
153
240
  for (const d of dirs) {
154
- const kubescapePath = resolveCmd('kubescape')
155
- if (!kubescapePath) {
156
- console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
157
- return 127
241
+ const kdirs = await findKustomizationDirs(d)
242
+ if (kdirs.length === 0) {
243
+ console.log(`run-k8s: kubescape scan ${d} (без kustomization сирий dir-скан)`)
244
+ const r = spawnSync(kubescapePath, ['scan', d, '--severity-threshold', 'high', ...exceptionsArgs], {
245
+ stdio: 'inherit',
246
+ shell: false
247
+ })
248
+ if (r.error && 'code' in r.error && r.error.code === 'ENOENT') {
249
+ console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
250
+ return 127
251
+ }
252
+ if (r.status !== 0) return r.status ?? 1
253
+ continue
254
+ }
255
+ if (kustomizePath === null) {
256
+ kustomizePath = resolveCmd('kustomize')
257
+ if (!kustomizePath) {
258
+ console.error('kustomize не знайдено в PATH. Встанови з https://kubectl.docs.kubernetes.io/installation/kustomize/')
259
+ return 127
260
+ }
158
261
  }
159
- const r = spawnSync(kubescapePath, ['scan', d, '--severity-threshold', 'high', ...exceptionsArgs], {
160
- stdio: 'inherit',
161
- shell: false
162
- })
163
- if (r.error && 'code' in r.error && r.error.code === 'ENOENT') {
164
- console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
165
- return 127
262
+ for (const kdir of kdirs) {
263
+ console.log(`run-k8s: kustomize build ${kdir} | kubescape scan -`)
264
+ const build = runKustomizeBuild(kustomizePath, kdir)
265
+ if (build.status !== 0) return build.status
266
+ const ks = runKubescapeStdin(kubescapePath, build.stdout, exceptionsArgs)
267
+ if (ks.enoent) {
268
+ console.error('kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme')
269
+ return 127
270
+ }
271
+ if (ks.status !== 0) return ks.status
166
272
  }
167
- if (r.status !== 0) return r.status ?? 1
168
273
  }
169
274
  return 0
170
275
  }
@@ -190,7 +295,7 @@ export async function runLintK8s() {
190
295
  const kc = runKubeconform(dirs)
191
296
  if (kc !== 0) return kc
192
297
 
193
- const ks = runKubescape(dirs, root)
298
+ const ks = await runKubescape(dirs, root)
194
299
  return ks
195
300
  }
196
301
 
@@ -85,6 +85,13 @@ deny contains "spec.egress: потрібен to.namespaceSelector: {} (інші
85
85
  not egress_has_cluster_namespace_selector(spec)
86
86
  }
87
87
 
88
+ deny contains "spec.egress: to.namespaceSelector: {} мусить мати непорожні ports — catch-all заборонено (k8s.mdc)" if {
89
+ is_np_doc
90
+ spec := object.get(input, "spec", null)
91
+ is_object(spec)
92
+ cluster_egress_rule_without_ports(spec)
93
+ }
94
+
88
95
  is_np_doc if input.kind == "NetworkPolicy"
89
96
 
90
97
  is_np_doc if startswith(object.get(input, "apiVersion", ""), "networking.k8s.io/")
@@ -156,3 +163,19 @@ egress_has_cluster_namespace_selector(spec) if {
156
163
  is_object(ns)
157
164
  count(ns) == 0
158
165
  }
166
+
167
+ cluster_egress_rule_without_ports(spec) if {
168
+ egress := object.get(spec, "egress", null)
169
+ is_array(egress)
170
+ some rule in egress
171
+ is_object(rule)
172
+ to_list := object.get(rule, "to", null)
173
+ is_array(to_list)
174
+ some peer in to_list
175
+ is_object(peer)
176
+ ns := object.get(peer, "namespaceSelector", null)
177
+ is_object(ns)
178
+ count(ns) == 0
179
+ ports := object.get(rule, "ports", [])
180
+ count(ports) == 0
181
+ }
@@ -30,3 +30,22 @@ spec:
30
30
  port: 443
31
31
  - to:
32
32
  - namespaceSelector: {}
33
+ ports:
34
+ - protocol: TCP
35
+ port: 80
36
+ - protocol: TCP
37
+ port: 443
38
+ - protocol: TCP
39
+ port: 5432
40
+ - protocol: TCP
41
+ port: 3306
42
+ - protocol: TCP
43
+ port: 1433
44
+ - protocol: TCP
45
+ port: 6379
46
+ - protocol: TCP
47
+ port: 8080
48
+ - protocol: TCP
49
+ port: 4317
50
+ - protocol: TCP
51
+ port: 4318