@nitra/cursor 1.13.44 → 1.13.48

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,11 +4,36 @@
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.48] - 2026-05-19
8
+
9
+ ### Changed
10
+
11
+ - `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).
12
+
13
+ ## [1.13.47] - 2026-05-19
14
+
15
+ ### Fixed
16
+
17
+ - `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`.
18
+
19
+ ## [1.13.46] - 2026-05-19
20
+
21
+ ### Changed
22
+
23
+ - `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`.
24
+
25
+ ## [1.13.45] - 2026-05-19
26
+
27
+ ### Fixed
28
+
29
+ - `inlineTemplateLinks` tests: оновлено очікувані рядки для фікстури `__fixtures__/inline-template/fix/foo/template/snippet.json` (перейшла на форматований варіант `{ "key": "val" }` ще в 1.13.38) та для інтеграційного тесту `security.mdc` (snippet `package.json` тепер multi-line після lint-проходу). Без зміни рантайм-логіки.
30
+ - `check-ga` тестова фікстура `setupCanonicalGaProject`: додано крок `Install conftest` у `.github/workflows/lint-ga.yml`, без якого `ga.lint_ga` rego-полісі забороняє workflow і `check()` повертав 1.
31
+
7
32
  ## [1.13.44] - 2026-05-18
8
33
 
9
34
  ### Added
10
35
 
11
- - `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`.
36
+ - `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`.
12
37
  - `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`.
13
38
 
14
39
  ## [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.44",
3
+ "version": "1.13.48",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,11 +3,16 @@
3
3
  * ув'язування `.avif`-двійників з посиланнями у `.vue`/`.html`.
4
4
  *
5
5
  * Дії під час `check image-avif`:
6
- * 1. `npx \@nitra/minify-image --src=. --write --avif` генерує AVIF-двійники.
7
- * 2. У кожному workspace-пакеті переписує raster-посилання у `.vue`/`.html` на `.avif`
6
+ * 1. **Pre-scan**: знайти в `.vue`/`.html` хоча б одне raster-посилання, яке потенційно
7
+ * можна переписати на AVIF-двійник (через `import x from '...png'` або
8
+ * `<img src="...png" />`). Пакети з opt-out `disable-avif: true` пропускаються.
9
+ * Якщо жодного raster-посилання не знайдено → exit 0 одразу (`npx --avif` не запускаємо,
10
+ * rewrite/cleanup-пасс теж пропускаємо — нічого не змінилось би).
11
+ * 2. `npx \@nitra/minify-image --src=. --write --avif` — генерує AVIF-двійники.
12
+ * 3. У кожному workspace-пакеті переписує raster-посилання у `.vue`/`.html` на `.avif`
8
13
  * (де AVIF-двійник реально існує на диску). Pakety з `"\@nitra/minify-image": {
9
14
  * "disable-avif": true }` у `package.json` пропускаються.
10
- * 3. Прибирає AVIF-сироти — `<name>.<ext>.avif`, на які не лишилось жодного посилання
15
+ * 4. Прибирає AVIF-сироти — `<name>.<ext>.avif`, на які не лишилось жодного посилання
11
16
  * у `.vue`/`.html` репозиторію, видаляються (умова правила: «AVIF лишається лише
12
17
  * там, де заміна вдалася»).
13
18
  *
@@ -69,7 +74,6 @@ const VUE_RASTER_STATIC_SRC_RE = /(?<![:\-_.])\bsrc\s*=\s*['"]([^'"\s]+\.(?:png|
69
74
  * є сиротами і підлягають видаленню.
70
75
  */
71
76
  const VUE_AVIF_REF_RE = /['"]([^'"\s]+\.(?:png|jpe?g|gif)\.avif)['"]/giu
72
- const RASTER_IMAGE_EXT_RE = /\.(?:png|jpe?g|gif)$/iu
73
77
 
74
78
  /**
75
79
  * Чи у `package.json` пакета вимкнено avif-перевірку Vue-імпортів.
@@ -279,24 +283,51 @@ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
279
283
  }
280
284
 
281
285
  /**
282
- * Чи є в репозиторії хоч один raster-файл, який мав би сенс конвертувати у AVIF.
283
- * Якщо немає `npx \@nitra/minify-image` нема що робити, тож зайвий запуск пропускаємо
284
- * (важливо у тестах: фікстурні `.png`-імпорти посилаються на неіснуючі файли, тож
285
- * minify-image все одно нічого не згенерує а зайвий npx-спавн повільний і робить шум).
286
+ * Pre-scan: чи є в `.vue`/`.html` хоча б одне raster-посилання, яке потенційно треба
287
+ * переписати на AVIF-двійник (через `import x from '...png'` або `<img src="...png" />`).
288
+ *
289
+ * Якщо false весь подальший етап `image-avif` пропускаємо: ні `npx --avif`, ні rewrite,
290
+ * ні cleanup-сиріт не дали б ніяких змін. Сенс — не запускати дорогий `npx @nitra/minify-image`
291
+ * у проєктах, де AVIF не вживається (а опційно і не плануються).
292
+ *
293
+ * Скан робиться тими самими regexp-ами, що й основний rewrite-пасс (`VUE_RASTER_IMPORT_RE`
294
+ * + `VUE_RASTER_STATIC_SRC_RE`), і ходить лише по `.vue`/`.html` у workspace-пакетах, що НЕ
295
+ * мають opt-out `"@nitra/minify-image": { "disable-avif": true }` (інакше їхні шаблони ми
296
+ * все одно не сканували б, тож вони не мають провокувати запуск AVIF-етапу).
286
297
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
287
- * @returns {Promise<boolean>} `true`, якщо знайдено принаймні один `.png/.jpe?g/.gif`
298
+ * @returns {Promise<boolean>} `true`, якщо знайдено принаймні одне raster-посилання
288
299
  */
289
- async function hasAnyRasterImage(ignorePaths) {
290
- let found = false
291
- await walkDir(
292
- process.cwd(),
293
- absPath => {
294
- if (found) return
295
- if (RASTER_IMAGE_EXT_RE.test(absPath)) found = true
296
- },
297
- ignorePaths
298
- )
299
- return found
300
+ async function hasAnyVueRasterReference(ignorePaths) {
301
+ const roots = await getMonorepoPackageRootDirs()
302
+ const absRootsByRel = new Map(roots.map(r => [r, join(process.cwd(), r)]))
303
+ for (const root of roots) {
304
+ const pkgPath = join(root, 'package.json')
305
+ if (existsSync(pkgPath)) {
306
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
307
+ if (packageHasAvifDisabled(pkg)) continue
308
+ }
309
+ const absRoot = absRootsByRel.get(root) ?? join(process.cwd(), root)
310
+ const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')
311
+ /** @type {string[]} */
312
+ const targetFiles = []
313
+ await walkDir(
314
+ absRoot,
315
+ absPath => {
316
+ if (!absPath.endsWith('.vue') && !absPath.endsWith('.html')) return
317
+ if (otherRootsAbs.some(other => absPath.startsWith(`${other}/`))) return
318
+ targetFiles.push(absPath)
319
+ },
320
+ ignorePaths
321
+ )
322
+ for (const absPath of targetFiles) {
323
+ const content = await readFile(absPath, 'utf8')
324
+ VUE_RASTER_IMPORT_RE.lastIndex = 0
325
+ if (VUE_RASTER_IMPORT_RE.test(content)) return true
326
+ VUE_RASTER_STATIC_SRC_RE.lastIndex = 0
327
+ if (VUE_RASTER_STATIC_SRC_RE.test(content)) return true
328
+ }
329
+ }
330
+ return false
300
331
  }
301
332
 
302
333
  /**
@@ -381,10 +412,15 @@ export async function check() {
381
412
 
382
413
  const ignorePaths = await loadCursorIgnorePaths(process.cwd())
383
414
 
384
- if (await hasAnyRasterImage(ignorePaths)) {
385
- runAvifGeneration()
415
+ if (!(await hasAnyVueRasterReference(ignorePaths))) {
416
+ pass(
417
+ 'image-avif: у .vue/.html немає raster-посилань для переписування — AVIF-генерація і cleanup пропущені'
418
+ )
419
+ return reporter.getExitCode()
386
420
  }
387
421
 
422
+ runAvifGeneration()
423
+
388
424
  /** @type {Set<string>} */
389
425
  const usedAvifAbs = new Set()
390
426
  /** @type {RewriteStats} */
@@ -1,17 +1,18 @@
1
1
  ---
2
2
  description: AVIF-двійники для raster-зображень з ув'язуванням у .vue/.html
3
- version: '1.3'
3
+ version: '1.4'
4
4
  globs: "**/*.{png,jpg,jpeg,gif,avif,vue,html}"
5
5
  alwaysApply: false
6
6
  ---
7
7
 
8
- AVIF-двійники (`<name>.<ext>.avif`) генерує **виключно** `npx @nitra/cursor check image-avif` — у `lint-image` прапорець `--avif` заборонений (це валідує правило `image-compress`). Перевірка робить три кроки в порядку:
8
+ AVIF-двійники (`<name>.<ext>.avif`) генерує **виключно** `npx @nitra/cursor check image-avif` — у `lint-image` прапорець `--avif` заборонений (це валідує правило `image-compress`). Перевірка робить чотири кроки в порядку:
9
9
 
10
- 1. Запускає `npx @nitra/minify-image --src=. --write --avif` (≥ **3.3.1**)генерує `<name>.<ext>.avif` поряд з кожним PNG/JPEG/GIF. CLI порівнює sha1 кожного raster-сорсу зі збереженим у `.n-minify-image.tsv` і перезаписує `<source>.avif` при зміні оригіналу.
11
- 2. Сканує `.vue` також `.html`) файли в кожному workspace-пакеті (root + workspaces) і автоматично переписує raster-посилання на AVIF-двійник у двох формах:
10
+ 1. **Pre-scan**: шукає у `.vue`/`.html` хоча б одне raster-посилання, яке потенційно треба переписати на AVIF-двійник (`import x from '...png'` або `<img src="...png" />`). Пакети з opt-out `disable-avif: true` пропускаються. **Якщо жодного raster-посилання не знайдено `check image-avif` завершується успіхом одразу: ні `npx --avif`, ні rewrite, ні cleanup-сиріт не виконуються** (нічого було б змінювати). Так уникаємо дорогого `npx @nitra/minify-image` у проєктах, де AVIF не вживається.
11
+ 2. Запускає `npx @nitra/minify-image --src=. --write --avif` (≥ **3.3.1**) генерує `<name>.<ext>.avif` поряд з кожним PNG/JPEG/GIF. CLI порівнює sha1 кожного raster-сорсу зі збереженим у `.n-minify-image.tsv` і перезаписує `<source>.avif` при зміні оригіналу.
12
+ 3. Сканує `.vue` (а також `.html`) файли в кожному workspace-пакеті (root + workspaces) і автоматично переписує raster-посилання на AVIF-двійник у двох формах:
12
13
  - **Імпорт-пов'язані** — `import x from '...png|jpg|jpeg|gif'` (далі `:src="x"` у шаблоні);
13
14
  - **Прямі статичні** — `<img src="...png" />` у `<template>` (Vite перетворює такий шлях на asset-імпорт на етапі збірки, тож вимога та сама).
14
- 3. Видаляє AVIF-сироти: ходить по всіх `<...>.avif` у репозиторії; якщо на двійник не лишилось жодного посилання у `.vue`/`.html` — файл видаляється. **AVIF на диску лишається лише там, де заміна реально відбулась** — тому невикористані оригінали не накопичують `.avif`-«хвости».
15
+ 4. Видаляє AVIF-сироти: ходить по всіх `<...>.avif` у репозиторії; якщо на двійник не лишилось жодного посилання у `.vue`/`.html` — файл видаляється. **AVIF на диску лишається лише там, де заміна реально відбулась** — тому невикористані оригінали не накопичують `.avif`-«хвости». **Зауваж**: cleanup виконується ЛИШЕ якщо pre-scan на кроці 1 знайшов хоча б одне raster-посилання — інакше осиротілі `.avif` залишаються недоторканими (видаляться вже наступним прогоном, коли в `.vue`/`.html` зʼявиться raster для конвертації).
15
16
 
16
17
  ```vue title="App.vue (після check image-avif)"
17
18
  <script setup>
@@ -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.36'
4
4
  globs: "**/k8s/**/*.yaml"
5
5
  alwaysApply: false
6
6
  ---
@@ -388,7 +388,7 @@ images:
388
388
 
389
389
  - **`kustomization.yaml`** — `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component`, `resources: [hpa.yaml, networkpolicy.yaml, pdb.yaml]` (відсортовано за алфавітом).
390
390
  - **`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`** дописує його автоматично.
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: {}` з **явним списком 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
392
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, **без** `metadata.namespace`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment, dev-like `minAvailable: 0`.
393
393
 
394
394
  Інші назви каталогу (`scale/`, `hpa-component/`, `pdb-component/`) — fail.
@@ -509,6 +509,25 @@ spec:
509
509
  port: 443
510
510
  - to:
511
511
  - namespaceSelector: {}
512
+ ports:
513
+ - protocol: TCP
514
+ port: 80
515
+ - protocol: TCP
516
+ port: 443
517
+ - protocol: TCP
518
+ port: 5432
519
+ - protocol: TCP
520
+ port: 3306
521
+ - protocol: TCP
522
+ port: 1433
523
+ - protocol: TCP
524
+ port: 6379
525
+ - protocol: TCP
526
+ port: 8080
527
+ - protocol: TCP
528
+ port: 4317
529
+ - protocol: TCP
530
+ port: 4318
512
531
  ```
513
532
 
514
533
  ```yaml title="k8s/components/hpa.yaml"
@@ -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