@nitra/cursor 1.8.130 → 1.8.132
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/bin/auto-rules.md +4 -0
- package/mdc/docker.mdc +66 -1
- package/mdc/js-mssql.mdc +59 -0
- package/mdc/php.mdc +132 -0
- package/package.json +1 -1
- package/scripts/auto-rules.mjs +60 -0
- package/scripts/check-docker.mjs +130 -4
- package/scripts/check-ga.mjs +207 -86
- package/scripts/check-js-mssql.mjs +203 -0
- package/scripts/check-k8s.mjs +12 -12
- package/scripts/check-php.mjs +80 -0
- package/scripts/run-php.mjs +125 -0
- package/scripts/utils/mssql-pool-scan.mjs +208 -0
package/bin/auto-rules.md
CHANGED
|
@@ -18,12 +18,16 @@ js-lint - якщо присутній хоч один js файл
|
|
|
18
18
|
|
|
19
19
|
js-pino - якщо присутній хоч один js файл, не в монорепо проекті з vue та директорії tempo
|
|
20
20
|
|
|
21
|
+
js-mssql - якщо в хоч одному package.json в секції dependencies присутній пакет mssql
|
|
22
|
+
|
|
21
23
|
k8s - якщо присутня хоч одна директорія k8s
|
|
22
24
|
|
|
23
25
|
nginx-default-tpl - якщо присутній хоч один файл з переліку - default.conf.template, default.conf, nginx.conf
|
|
24
26
|
|
|
25
27
|
npm-module - якщо в корені присутня директорія npm
|
|
26
28
|
|
|
29
|
+
php - якщо присутній хоч один php файл
|
|
30
|
+
|
|
27
31
|
style-lint - якщо присутній хоч один vue або css файл
|
|
28
32
|
|
|
29
33
|
text - завжди
|
package/mdc/docker.mdc
CHANGED
|
@@ -14,10 +14,75 @@ alwaysApply: false
|
|
|
14
14
|
Також Dockerfile/Containerfile **має бути multistage build**: окремий build stage (залежності/компіляція) і окремий runtime stage. У фінальному stage дозволені лише мінімальні базові образи:
|
|
15
15
|
|
|
16
16
|
- **backend**: `mirror.gcr.io/library/alpine:*`
|
|
17
|
-
- **frontend**: `mirror.gcr.io/library/nginx:*`
|
|
17
|
+
- **frontend**: `mirror.gcr.io/library/nginx:*` aбо `mirror.gcr.io/openresty/openresty:*`
|
|
18
18
|
|
|
19
19
|
Це гарантує, що результуючий образ містить лише runtime (alpine) або nginx, без build tooling і node_modules.
|
|
20
20
|
|
|
21
|
+
## компіляція
|
|
22
|
+
|
|
23
|
+
Якщо проект має bun install крок, та не є фронтенд проектом (тобто не має bun build крок), то потрібно щоб була компіляція коду, і далі у фінальному образі був тільки бінарник і Цей образ не містив компілятора, npm, Bun — тільки runtime libs. Наприклад:
|
|
24
|
+
|
|
25
|
+
```dockerfile
|
|
26
|
+
FROM mirror.gcr.io/oven/bun:alpine AS build-env
|
|
27
|
+
|
|
28
|
+
WORKDIR /app
|
|
29
|
+
|
|
30
|
+
ENV NODE_ENV=production
|
|
31
|
+
|
|
32
|
+
COPY package.json .
|
|
33
|
+
COPY bunfig.toml .
|
|
34
|
+
|
|
35
|
+
RUN bun install --production
|
|
36
|
+
|
|
37
|
+
COPY ./src ./src
|
|
38
|
+
|
|
39
|
+
# Компілюємо в бінарник
|
|
40
|
+
RUN bun build --compile --outfile app ./src/index.js
|
|
41
|
+
|
|
42
|
+
FROM mirror.gcr.io/library/alpine:latest
|
|
43
|
+
|
|
44
|
+
# (libstdc++ libgcc) для Bun runtime, (tzdata) для часового поясу
|
|
45
|
+
RUN apk add --no-cache libstdc++ libgcc tzdata
|
|
46
|
+
|
|
47
|
+
WORKDIR /app
|
|
48
|
+
|
|
49
|
+
COPY --from=build-env /app/app ./app
|
|
50
|
+
|
|
51
|
+
CMD ["./app"]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## не превілейований образ
|
|
55
|
+
|
|
56
|
+
Для всих образів потрібно щоб використовувся non root принцип, наприклад:
|
|
57
|
+
|
|
58
|
+
```dockerfile
|
|
59
|
+
# Stage 1
|
|
60
|
+
FROM oven/bun:alpine AS build-env
|
|
61
|
+
WORKDIR /app
|
|
62
|
+
COPY package.json bunfig.toml .
|
|
63
|
+
RUN bun install --production
|
|
64
|
+
COPY ./src ./src
|
|
65
|
+
RUN bun build --compile --outfile app ./src/index.js
|
|
66
|
+
|
|
67
|
+
# Stage 2
|
|
68
|
+
FROM alpine:latest
|
|
69
|
+
RUN apk add --no-cache libstdc++ libgcc tzdata
|
|
70
|
+
|
|
71
|
+
# ✅ Додати non-root user
|
|
72
|
+
RUN addgroup -g 1000 app && adduser -D -u 1000 -G app app
|
|
73
|
+
|
|
74
|
+
WORKDIR /app
|
|
75
|
+
|
|
76
|
+
# ✅ Змінити власника файлу
|
|
77
|
+
COPY --from=build-env --chown=app:app /app/app ./app
|
|
78
|
+
|
|
79
|
+
# ✅ Запускати як non-root
|
|
80
|
+
USER app
|
|
81
|
+
|
|
82
|
+
CMD ["./app"]
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
|
|
21
86
|
## Область
|
|
22
87
|
|
|
23
88
|
- Усі файли з іменем **`Dockerfile`** або **`Dockerfile.*`** (наприклад `Dockerfile.prod`) у репозиторії, крім ігнорованих каталогів (`node_modules`, `.git`, `dist`, …) — як у **`check-docker.mjs`**.
|
package/mdc/js-mssql.mdc
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Використання mssql в nodejs
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
version: '1.1'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Якщо в проекті в будь-якому `package.json` в секції `dependencies` присутній пакет **`mssql`**,
|
|
8
|
+
то його версія повинна бути не менше **12.5.0**.
|
|
9
|
+
|
|
10
|
+
Потрібно використовувати connection pool (sql.ConnectionPool) як singleton, а НЕ створювати підключення на кожен запит.
|
|
11
|
+
|
|
12
|
+
tagged template треба викликати на request-обʼєкті цього пулу:
|
|
13
|
+
```javascript
|
|
14
|
+
javascript// db.js
|
|
15
|
+
import sql from 'mssql';
|
|
16
|
+
|
|
17
|
+
let poolPromise;
|
|
18
|
+
|
|
19
|
+
export function getPool() {
|
|
20
|
+
if (!poolPromise) {
|
|
21
|
+
const pool = new sql.ConnectionPool(config);
|
|
22
|
+
poolPromise = pool.connect().catch(err => {
|
|
23
|
+
poolPromise = undefined; // дозволити повторну спробу
|
|
24
|
+
throw err;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return poolPromise;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
// usage
|
|
33
|
+
import { getPool } from './db.js';
|
|
34
|
+
|
|
35
|
+
const pool = await getPool();
|
|
36
|
+
const userId = 42;
|
|
37
|
+
const status = 'active';
|
|
38
|
+
|
|
39
|
+
// Tagged template на request — це працює
|
|
40
|
+
const result = await pool.request().query`
|
|
41
|
+
SELECT * FROM users
|
|
42
|
+
WHERE id = ${userId} AND status = ${status}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Ключове: pool.request().query\...`— бекті́ки післяquery`, без круглих дужок. Це той самий tagged template, тільки контекст — конкретний пул, а не глобальний.
|
|
48
|
+
|
|
49
|
+
Що НЕ робити
|
|
50
|
+
javascript// ❌ Це не tagged template — це конкатенація рядка перед викликом
|
|
51
|
+
```javascript
|
|
52
|
+
await pool.request().query(`SELECT * FROM users WHERE id = ${userId}`);
|
|
53
|
+
// ↑ круглі дужки замість бекті́ків = звичайна інтерполяція = SQL injection
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Різниця між query\...`іquery(`...`)` — критична. Перше безпечне, друге — діра.
|
|
57
|
+
Потрібно використовувати перше. Потрібно шукати в коді друге і заміняти на перше.
|
|
58
|
+
|
|
59
|
+
Перевірка: `npx @nitra/cursor check js-mssql`.
|
package/mdc/php.mdc
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: PHP
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
version: '1.0'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Весь код повинен відповідати PHP 8.5, перевіряти за допомогою PHPCompatibility, конвертувати за допомогою Rector.
|
|
8
|
+
|
|
9
|
+
Код повинен бути добре документований за допомогою phpDoc-ів:
|
|
10
|
+
|
|
11
|
+
```php
|
|
12
|
+
/** @param array<int, string> $items */
|
|
13
|
+
function process($items) {
|
|
14
|
+
foreach ($items as $id => $name) {
|
|
15
|
+
// PHPStan знає, що $id — int, $name — string
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Потрібно додати до lint-php
|
|
21
|
+
|
|
22
|
+
### PHP_CodeSniffer (phpcs) і пакет `squizlabs/php_codesniffer`
|
|
23
|
+
|
|
24
|
+
Стиль, базові ризики, за потреби окремі стандарти. Для **SQL injection**, **XSS**, **небезпечні функції** — PHPCS + **php-security-audit** (стандарт `Security`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
phpcs --standard=Security app/
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### PHPStan
|
|
31
|
+
|
|
32
|
+
Статичний аналіз типів і смертельних слабких місць; **PHPStan Taint Analysis** (`phpstan/phpstan-taint-analysis`) — для потоку даних (taint) у стилі security-аналізу.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
vendor/bin/phpstan analyse
|
|
36
|
+
# після підключення розширення taint — у phpstan.neon/ neon.dist
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### PHP-CS-Fixer
|
|
40
|
+
|
|
41
|
+
Форматтер/фіксер коду; у **lint** зазвичай перевірка без змін (`--dry-run`).
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
vendor/bin/php-cs-fixer fix --dry-run --diff
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Psalm
|
|
48
|
+
|
|
49
|
+
Альтернативний/додатковий аналізатор; **Psalm Security Plugin** (`vimeo/psalm` + пакет на кшталт `psalm/plugin-security` залежно від вибраної збірки) — для security-орієнтованих правил.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
vendor/bin/psalm
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `composer audit`
|
|
56
|
+
|
|
57
|
+
Перевірка відомих вразливостей залежностей за даними **Packagist/security-advisories**.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
composer audit
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## lint-php
|
|
64
|
+
|
|
65
|
+
`composer`-інструмененти не мають єдиного CLI, який сам обходить репозиторій, тому `lint-php` зручно делегувати у JS-скрипт-обгортку (як `lint-docker`, `lint-k8s`).
|
|
66
|
+
|
|
67
|
+
```json title="package.json"
|
|
68
|
+
{
|
|
69
|
+
"scripts": {
|
|
70
|
+
"lint-php": "bun ./npm/scripts/run-php.mjs"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Скрипт `run-php.mjs`:
|
|
76
|
+
|
|
77
|
+
- якщо `composer.json` у корені відсутній — вихід 0 (перевірка пропущена);
|
|
78
|
+
- якщо `composer.json` є, але `composer` не знайдено в PATH — це помилка;
|
|
79
|
+
- `composer audit` — обовʼязковий;
|
|
80
|
+
- `vendor/bin/php-cs-fixer`, `vendor/bin/phpcs`, `vendor/bin/phpstan`, `vendor/bin/psalm` — запускаються лише якщо встановлені (інакше крок пропускається з повідомленням).
|
|
81
|
+
|
|
82
|
+
## CI: `.github/workflows/lint-php.yml`
|
|
83
|
+
|
|
84
|
+
```yaml title=".github/workflows/lint-php.yml"
|
|
85
|
+
name: Lint PHP
|
|
86
|
+
|
|
87
|
+
on:
|
|
88
|
+
push:
|
|
89
|
+
branches:
|
|
90
|
+
- dev
|
|
91
|
+
- main
|
|
92
|
+
paths:
|
|
93
|
+
- '**/*.php'
|
|
94
|
+
- 'composer.json'
|
|
95
|
+
- 'composer.lock'
|
|
96
|
+
- 'phpstan.neon'
|
|
97
|
+
- 'phpstan.neon.dist'
|
|
98
|
+
- 'psalm.xml'
|
|
99
|
+
- '.github/workflows/lint-php.yml'
|
|
100
|
+
|
|
101
|
+
pull_request:
|
|
102
|
+
branches:
|
|
103
|
+
- dev
|
|
104
|
+
- main
|
|
105
|
+
|
|
106
|
+
concurrency:
|
|
107
|
+
group: ${{ github.ref }}-${{ github.workflow }}
|
|
108
|
+
cancel-in-progress: true
|
|
109
|
+
|
|
110
|
+
jobs:
|
|
111
|
+
php:
|
|
112
|
+
runs-on: ubuntu-latest
|
|
113
|
+
permissions:
|
|
114
|
+
contents: read
|
|
115
|
+
steps:
|
|
116
|
+
- uses: actions/checkout@v6
|
|
117
|
+
with:
|
|
118
|
+
persist-credentials: false
|
|
119
|
+
|
|
120
|
+
- uses: ./.github/actions/setup-bun-deps
|
|
121
|
+
|
|
122
|
+
- name: Install PHP
|
|
123
|
+
uses: shivammathur/setup-php@v2
|
|
124
|
+
with:
|
|
125
|
+
php-version: '8.5'
|
|
126
|
+
|
|
127
|
+
- name: Install Composer dependencies
|
|
128
|
+
run: composer install --no-interaction --no-progress --prefer-dist
|
|
129
|
+
|
|
130
|
+
- name: Lint PHP
|
|
131
|
+
run: bun run lint-php
|
|
132
|
+
```
|
package/package.json
CHANGED
package/scripts/auto-rules.mjs
CHANGED
|
@@ -25,10 +25,12 @@ export const AUTO_RULE_ORDER = Object.freeze([
|
|
|
25
25
|
'ga',
|
|
26
26
|
'graphql',
|
|
27
27
|
'js-lint',
|
|
28
|
+
'js-mssql',
|
|
28
29
|
'js-pino',
|
|
29
30
|
'k8s',
|
|
30
31
|
'nginx-default-tpl',
|
|
31
32
|
'npm-module',
|
|
33
|
+
'php',
|
|
32
34
|
'style-lint',
|
|
33
35
|
'text',
|
|
34
36
|
'vue'
|
|
@@ -41,10 +43,59 @@ const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
|
|
|
41
43
|
const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/iu
|
|
42
44
|
const STYLE_RE = /\.(?:css|vue)$/iu
|
|
43
45
|
const VUE_RE = /\.vue$/iu
|
|
46
|
+
const PHP_RE = /\.php$/iu
|
|
44
47
|
const NGINX_DEFAULT_FILES = new Set(['default.conf.template', 'default.conf', 'nginx.conf'])
|
|
45
48
|
const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
|
|
46
49
|
const DEFAULT_DISABLED_LIST = Object.freeze([])
|
|
47
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Чи є `mssql` у `dependencies` хоча б одного `package.json` у репозиторії.
|
|
53
|
+
* @param {string} root абсолютний шлях до кореня репозиторію
|
|
54
|
+
* @returns {Promise<boolean>} true, якщо знайдено `dependencies.mssql`
|
|
55
|
+
*/
|
|
56
|
+
async function hasMssqlDependencyInAnyPackageJson(root) {
|
|
57
|
+
let found = false
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Рекурсивний обхід каталогу з пропуском службових директорій.
|
|
61
|
+
* @param {string} dir абсолютний шлях каталогу
|
|
62
|
+
* @returns {Promise<void>}
|
|
63
|
+
*/
|
|
64
|
+
async function walk(dir) {
|
|
65
|
+
if (found) return
|
|
66
|
+
let entries
|
|
67
|
+
try {
|
|
68
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
69
|
+
} catch {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (found) return
|
|
74
|
+
const absPath = join(dir, entry.name)
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
|
|
77
|
+
if (!isIgnoredDir) {
|
|
78
|
+
await walk(absPath)
|
|
79
|
+
}
|
|
80
|
+
} else if (entry.isFile() && entry.name === 'package.json') {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(await readFile(absPath, 'utf8'))
|
|
83
|
+
const deps = parsed?.dependencies
|
|
84
|
+
if (deps && typeof deps === 'object' && !Array.isArray(deps) && Object.hasOwn(deps, 'mssql')) {
|
|
85
|
+
found = true
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
/* ігноруємо пошкоджені/недоступні package.json */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await walk(root)
|
|
96
|
+
return found
|
|
97
|
+
}
|
|
98
|
+
|
|
48
99
|
/**
|
|
49
100
|
* Фіксує ознаки, що залежать лише від імені підкаталогу.
|
|
50
101
|
* @param {string} dirName імʼя каталогу
|
|
@@ -71,6 +122,7 @@ function updateDirFacts(dirName, facts) {
|
|
|
71
122
|
* hasDockerfile: boolean,
|
|
72
123
|
* hasJsLikeSource: boolean,
|
|
73
124
|
* hasNginxDefaultTplFile: boolean,
|
|
125
|
+
* hasPhpSource: boolean,
|
|
74
126
|
* hasVueOrCssSource: boolean,
|
|
75
127
|
* hasVueSource: boolean
|
|
76
128
|
* }} facts агреговані факти
|
|
@@ -89,6 +141,9 @@ function updateFileFacts(fileName, relPath, facts) {
|
|
|
89
141
|
if (VUE_RE.test(relPath)) {
|
|
90
142
|
facts.hasVueSource = true
|
|
91
143
|
}
|
|
144
|
+
if (PHP_RE.test(relPath)) {
|
|
145
|
+
facts.hasPhpSource = true
|
|
146
|
+
}
|
|
92
147
|
if (STYLE_RE.test(relPath)) {
|
|
93
148
|
facts.hasVueOrCssSource = true
|
|
94
149
|
}
|
|
@@ -213,6 +268,7 @@ export function isMonorepoPackage(packageJson) {
|
|
|
213
268
|
* hasK8sDir: boolean,
|
|
214
269
|
* hasNginxDefaultTplFile: boolean,
|
|
215
270
|
* hasTempoDir: boolean,
|
|
271
|
+
* hasPhpSource: boolean,
|
|
216
272
|
* hasVueSource: boolean,
|
|
217
273
|
* hasVueOrCssSource: boolean
|
|
218
274
|
* }>} агреговані факти
|
|
@@ -226,6 +282,7 @@ export async function collectAutoRuleFacts(root) {
|
|
|
226
282
|
hasK8sDir: false,
|
|
227
283
|
hasNginxDefaultTplFile: false,
|
|
228
284
|
hasTempoDir: false,
|
|
285
|
+
hasPhpSource: false,
|
|
229
286
|
hasVueSource: false,
|
|
230
287
|
hasVueOrCssSource: false
|
|
231
288
|
}
|
|
@@ -295,6 +352,7 @@ export async function detectAutoRulesAndSkills({
|
|
|
295
352
|
)
|
|
296
353
|
const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
|
|
297
354
|
const isMonorepo = isMonorepoPackage(packageJsonParsed)
|
|
355
|
+
const hasMssqlDependency = await hasMssqlDependencyInAnyPackageJson(root)
|
|
298
356
|
|
|
299
357
|
/** @type {string[]} */
|
|
300
358
|
const detectedRules = []
|
|
@@ -332,10 +390,12 @@ export async function detectAutoRulesAndSkills({
|
|
|
332
390
|
{ enabled: facts.hasGaWorkflowsDir, id: 'ga' },
|
|
333
391
|
{ enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
|
|
334
392
|
{ enabled: facts.hasJsLikeSource, id: 'js-lint' },
|
|
393
|
+
{ enabled: hasMssqlDependency, id: 'js-mssql' },
|
|
335
394
|
{ enabled: facts.hasJsLikeSource && !(isMonorepo && facts.hasVueSource && facts.hasTempoDir), id: 'js-pino' },
|
|
336
395
|
{ enabled: facts.hasK8sDir, id: 'k8s' },
|
|
337
396
|
{ enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
|
|
338
397
|
{ enabled: npmDirExists, id: 'npm-module' },
|
|
398
|
+
{ enabled: facts.hasPhpSource, id: 'php' },
|
|
339
399
|
{ enabled: facts.hasVueOrCssSource, id: 'style-lint' }
|
|
340
400
|
]
|
|
341
401
|
for (const item of autoRuleChecks) {
|
package/scripts/check-docker.mjs
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
* Також перевіряє, що Dockerfile/Containerfile має **multistage build** і що фінальний stage
|
|
8
8
|
* використовує мінімальний runtime-образ:
|
|
9
9
|
* - backend: `mirror.gcr.io/library/alpine:*`
|
|
10
|
-
* - frontend: `mirror.gcr.io/library/nginx:*`
|
|
10
|
+
* - frontend: `mirror.gcr.io/library/nginx:*` або `mirror.gcr.io/openresty/openresty:*`
|
|
11
|
+
*
|
|
12
|
+
* Якщо в Dockerfile є крок `bun install` і це не frontend-образ (фінальний stage — alpine),
|
|
13
|
+
* то очікується компіляція в один бінарник через `bun build --compile` у build stage, а у
|
|
14
|
+
* фінальному stage не повинно залишатися build tooling (Bun/Node).
|
|
11
15
|
*
|
|
12
16
|
* Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
|
|
13
|
-
* runtime (alpine) або
|
|
17
|
+
* runtime (alpine), nginx або openresty.
|
|
14
18
|
*
|
|
15
19
|
* Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
|
|
16
20
|
* тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
|
|
@@ -25,6 +29,10 @@ import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
|
25
29
|
import { walkDir } from './utils/walkDir.mjs'
|
|
26
30
|
|
|
27
31
|
const NEWLINE_RE = /\r?\n/
|
|
32
|
+
const BUN_INSTALL_RE = /\bbun\s+(?:install|i)\b/iu
|
|
33
|
+
const BUN_BUILD_COMPILE_RE = /\bbun\s+build\b[^\n]*\s--compile\b/iu
|
|
34
|
+
const BUN_WORD_RE = /\bbun\b/iu
|
|
35
|
+
const USER_LINE_RE = /^\s*USER\s+([^\s#]+)/iu
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
38
|
* @typedef {{
|
|
@@ -74,12 +82,38 @@ export function parseFromStages(fileContent) {
|
|
|
74
82
|
return out
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
const RUNTIME_IMAGES = /** @type {const} */ ([
|
|
85
|
+
const RUNTIME_IMAGES = /** @type {const} */ ([
|
|
86
|
+
'mirror.gcr.io/library/alpine',
|
|
87
|
+
'mirror.gcr.io/library/nginx',
|
|
88
|
+
'mirror.gcr.io/openresty/openresty'
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Розбиває Dockerfile на stages за `FROM` (порожній масив, якщо FROM немає).
|
|
93
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
94
|
+
* @returns {Array<{ from: FromStage, stageContent: string }>} stages з `FROM` і вмістом stage
|
|
95
|
+
*/
|
|
96
|
+
export function splitDockerfileStages(fileContent) {
|
|
97
|
+
const stages = parseFromStages(fileContent)
|
|
98
|
+
if (stages.length === 0) return []
|
|
99
|
+
|
|
100
|
+
const lines = fileContent.split(NEWLINE_RE)
|
|
101
|
+
/** @type {Array<{ from: FromStage, stageContent: string }>} */
|
|
102
|
+
const out = []
|
|
103
|
+
|
|
104
|
+
for (const [idx, from] of stages.entries()) {
|
|
105
|
+
const startLineIdx = Math.max(0, from.line - 1)
|
|
106
|
+
const next = stages[idx + 1]
|
|
107
|
+
const endLineExclusive = next ? Math.max(0, next.line - 1) : lines.length
|
|
108
|
+
out.push({ from, stageContent: lines.slice(startLineIdx, endLineExclusive).join('\n') })
|
|
109
|
+
}
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
78
112
|
|
|
79
113
|
/**
|
|
80
114
|
* Перевіряє базові вимоги до структури Dockerfile:
|
|
81
115
|
* - multistage: мінімум 2 FROM
|
|
82
|
-
* - фінальний FROM: alpine
|
|
116
|
+
* - фінальний FROM: alpine/nginx/openresty з mirror.gcr.io
|
|
83
117
|
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
84
118
|
* @returns {string | null} повідомлення помилки або null
|
|
85
119
|
*/
|
|
@@ -103,6 +137,88 @@ export function getMultistageAndRuntimeHint(fileContent) {
|
|
|
103
137
|
return null
|
|
104
138
|
}
|
|
105
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Перевіряє вимогу "компіляція в бінарник" для bun-проєктів на backend runtime.
|
|
142
|
+
*
|
|
143
|
+
* Тригер:
|
|
144
|
+
* - у Dockerfile є крок `bun install` (або `bun i`);
|
|
145
|
+
* - фінальний FROM — `mirror.gcr.io/library/alpine:*` (тобто не nginx/openresty frontend).
|
|
146
|
+
*
|
|
147
|
+
* Очікування:
|
|
148
|
+
* - у build stage є `bun build --compile`;
|
|
149
|
+
* - у фінальному stage немає викликів `bun` (залишків build tooling).
|
|
150
|
+
*
|
|
151
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
152
|
+
* @returns {string | null} повідомлення помилки або null
|
|
153
|
+
*/
|
|
154
|
+
export function getBunCompileHint(fileContent) {
|
|
155
|
+
const stages = splitDockerfileStages(fileContent)
|
|
156
|
+
if (stages.length === 0) return null
|
|
157
|
+
|
|
158
|
+
const last = stages.at(-1)
|
|
159
|
+
const lastImage = (last?.from.image || '').split('@')[0] || ''
|
|
160
|
+
const lastLower = lastImage.toLowerCase()
|
|
161
|
+
|
|
162
|
+
const hasBunInstall = BUN_INSTALL_RE.test(fileContent)
|
|
163
|
+
const isFinalAlpine = lastLower.startsWith('mirror.gcr.io/library/alpine:')
|
|
164
|
+
const isFinalFrontend =
|
|
165
|
+
lastLower.startsWith('mirror.gcr.io/library/nginx:') || lastLower.startsWith('mirror.gcr.io/openresty/openresty:')
|
|
166
|
+
|
|
167
|
+
if (!hasBunInstall) return null
|
|
168
|
+
if (!isFinalAlpine) return null
|
|
169
|
+
if (isFinalFrontend) return null
|
|
170
|
+
|
|
171
|
+
const hasCompile = BUN_BUILD_COMPILE_RE.test(fileContent)
|
|
172
|
+
if (!hasCompile) {
|
|
173
|
+
return 'є `bun install`, але немає `bun build --compile` — для backend-образу потрібно компілювати застосунок у бінарник (docker.mdc: компіляція)'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const lastStageContent = last?.stageContent || ''
|
|
177
|
+
if (BUN_WORD_RE.test(lastStageContent)) {
|
|
178
|
+
return 'фінальний stage не має містити Bun (RUN/CMD/ENTRYPOINT з `bun`) — залиш у runtime stage лише бінарник і runtime libs (docker.mdc: компіляція)'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Перевіряє вимогу "non-root" у фінальному runtime stage (docker.mdc).
|
|
186
|
+
*
|
|
187
|
+
* Очікування:
|
|
188
|
+
* - у фінальному stage має бути інструкція `USER <name|uid>`;
|
|
189
|
+
* - користувач не має бути `root` і не має бути `0`.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
192
|
+
* @returns {string | null} повідомлення помилки або null
|
|
193
|
+
*/
|
|
194
|
+
export function getNonRootRuntimeHint(fileContent) {
|
|
195
|
+
const stages = splitDockerfileStages(fileContent)
|
|
196
|
+
if (stages.length === 0) return null
|
|
197
|
+
|
|
198
|
+
const last = stages.at(-1)
|
|
199
|
+
const lastStageContent = last?.stageContent || ''
|
|
200
|
+
|
|
201
|
+
/** @type {string | null} */
|
|
202
|
+
let lastUserToken = null
|
|
203
|
+
for (const line of lastStageContent.split(NEWLINE_RE)) {
|
|
204
|
+
const m = line.match(USER_LINE_RE)
|
|
205
|
+
if (m) {
|
|
206
|
+
lastUserToken = (m[1] || '').replaceAll('"', '').replaceAll("'", '')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!lastUserToken) {
|
|
211
|
+
return 'у фінальному stage має бути `USER <non-root>` (наприклад `USER app`) — принцип non-root (docker.mdc: не превілейований образ)'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const normalized = lastUserToken.trim().toLowerCase()
|
|
215
|
+
if (normalized === 'root' || normalized === '0') {
|
|
216
|
+
return `фінальний stage має запускатися не від root: зараз \`USER ${lastUserToken}\` (docker.mdc: не превілейований образ)`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
106
222
|
/**
|
|
107
223
|
* Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
|
|
108
224
|
* @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
|
|
@@ -134,6 +250,16 @@ export async function check() {
|
|
|
134
250
|
fail(`${rel} (multistage): ${multistageHint}`)
|
|
135
251
|
}
|
|
136
252
|
|
|
253
|
+
const compileHint = getBunCompileHint(content)
|
|
254
|
+
if (compileHint) {
|
|
255
|
+
fail(`${rel} (compile): ${compileHint}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const nonRootHint = getNonRootRuntimeHint(content)
|
|
259
|
+
if (nonRootHint) {
|
|
260
|
+
fail(`${rel} (non-root): ${nonRootHint}`)
|
|
261
|
+
}
|
|
262
|
+
|
|
137
263
|
const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
|
|
138
264
|
const tail = (stdout + stderr).trim()
|
|
139
265
|
if (ok) {
|