@dzhechkov/p-replicator 1.10.3 → 1.12.0
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/.dz-manifest.json +119 -47
- package/CHANGELOG.md +85 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +3 -3
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/04_api_reference.md +11 -5
- package/README/eng/README.md +1 -1
- package/README/ru/01_quickstart.md +3 -3
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/04_api_reference.md +11 -5
- package/README/ru/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +154 -38
- package/bin/cli.js +0 -0
- package/package.json +15 -13
- package/sbom.json +226 -46
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/rule-components.json +11 -0
- package/src/utils.js +3 -8
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/commands/feature.md +57 -9
- package/templates/.claude/commands/go.md +11 -0
- package/templates/.claude/commands/harvest.md +41 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +10 -1
- package/templates/.claude/commands/start.md +8 -0
- package/templates/.claude/hooks/check-ports.cjs +409 -20
- package/templates/.claude/hooks/session-insights.cjs +158 -25
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/hooks/write-insight.cjs +253 -0
- package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
- package/templates/.claude/rules/docker-ports.md +41 -19
- package/templates/.claude/rules/feature-lifecycle.md +14 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- package/templates/.claude/rules/replicate-pipeline.md +4 -2
- package/templates/.claude/rules/skill-interface-protocol.md +1 -0
- package/templates/.claude/rules/swarm-file-evidence.md +46 -0
- package/templates/.claude/settings.json +13 -1
- package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +86 -16
- package/tests/e2e/lifecycle.test.js +55 -9
- package/tests/e2e/packed-insights-writer.test.js +308 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
- package/tests/snapshot/baseline.json +24 -20
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- package/tests/unit/check-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +729 -2
- package/tests/unit/db-port-rule.test.js +36 -5
- package/tests/unit/detection-ladder-contract.test.js +302 -0
- package/tests/unit/detection-ladder-registry.test.js +52 -0
- package/tests/unit/doctor-insight-flow.test.js +315 -0
- package/tests/unit/external-dependency-check.test.js +19 -19
- package/tests/unit/honest-failure-rules.test.js +492 -0
- package/tests/unit/hooks-project-anchored.test.js +67 -3
- package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
- package/tests/unit/insights-dz-delegation.test.js +197 -0
- package/tests/unit/insights-writer.test.js +285 -0
- package/tests/unit/shipped-suite-context.test.js +3 -1
- package/tests/unit/traceability-machine-ids.test.js +413 -0
- package/tests/unit/traceability-negative-fixture.test.js +322 -0
- package/tests/unit/utils.test.js +3 -2
- package/LICENSE +0 -21
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Invoke it deliberately:
|
|
10
10
|
*
|
|
11
11
|
* node .claude/hooks/check-ports.cjs [path-to-project | path-to-compose-file]
|
|
12
|
+
* node .claude/hooks/check-ports.cjs --machine
|
|
12
13
|
*
|
|
13
14
|
* Exit codes — three, and the third is the point:
|
|
14
15
|
* 0 the rule holds
|
|
@@ -24,9 +25,18 @@
|
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
const fs = require('node:fs');
|
|
28
|
+
const net = require('node:net');
|
|
27
29
|
const path = require('node:path');
|
|
28
30
|
const { spawnSync } = require('node:child_process');
|
|
29
31
|
|
|
32
|
+
const SUBPROCESS_TIMEOUT_MS = 5000;
|
|
33
|
+
const SUBPROCESS_OPTIONS = {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
timeout: SUBPROCESS_TIMEOUT_MS,
|
|
36
|
+
killSignal: 'SIGKILL',
|
|
37
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
38
|
+
};
|
|
39
|
+
|
|
30
40
|
// Storage recognised the way the rule names it — by image and by well-known port. Both lists live
|
|
31
41
|
// here, side by side, so a reader extending one can see the other.
|
|
32
42
|
// Recognition is a HEURISTIC and stays one — but it must not be a substring match. A substring
|
|
@@ -36,8 +46,21 @@ const { spawnSync } = require('node:child_process');
|
|
|
36
46
|
const STORAGE_NAMES = /^(postgres|postgresql|pgvector|mysql|mariadb|percona|mongo|mongodb|redis|valkey|keydb|elasticsearch|opensearch|minio|rabbitmq|memcached|clickhouse|cassandra|scylla|neo4j|influxdb|timescaledb|mssql|sqlserver|couchdb|etcd)$/i;
|
|
37
47
|
const STORAGE_PORT = new Set([5432, 3306, 27017, 6379, 9200, 9300, 5672, 11211, 9000, 8123,
|
|
38
48
|
1433, 9042, 7687, 8086, 2379, 5984]);
|
|
49
|
+
const PASSWORD_STORAGE_NAMES = /^(postgres|mysql|mariadb|mongo)$/i;
|
|
50
|
+
const NO_PASSWORD_STORAGE_NAMES = /^(redis|valkey|keydb|memcached)$/i;
|
|
39
51
|
const PROXY_NAMES = /^(caddy|nginx|traefik|haproxy|envoy)$/i;
|
|
40
52
|
|
|
53
|
+
const PASSWORD_STORAGE_EXPOSURE = 'хранилище опубликовано наружу: слабый или подобранный пароль '
|
|
54
|
+
+ 'вместе с возможностями сервера (например, COPY … TO PROGRAM) даёт путь к компрометации. '
|
|
55
|
+
+ 'Убрать ports: целиком и обращаться к сервису по имени в compose-сети; для доступа только с '
|
|
56
|
+
+ 'хоста привязать к 127.0.0.1/::1';
|
|
57
|
+
const NO_PASSWORD_STORAGE_EXPOSURE = 'хранилище опубликовано наружу: образ этого класса по умолчанию '
|
|
58
|
+
+ 'не требует пароля, поэтому публикация сразу даёт доступ без аутентификации. Убрать ports: '
|
|
59
|
+
+ 'целиком и обращаться к сервису по имени в compose-сети; для доступа только с хоста привязать '
|
|
60
|
+
+ 'к 127.0.0.1/::1';
|
|
61
|
+
const NEUTRAL_STORAGE_EXPOSURE = 'хранилище опубликовано наружу. Убрать ports: целиком, либо '
|
|
62
|
+
+ 'привязать к 127.0.0.1';
|
|
63
|
+
|
|
41
64
|
/** `mcr.microsoft.com/mssql/server:2022` → `server`; `postgres:16` → `postgres`. */
|
|
42
65
|
function imageName(image) {
|
|
43
66
|
const noDigest = String(image || '').split('@')[0];
|
|
@@ -46,16 +69,19 @@ function imageName(image) {
|
|
|
46
69
|
}
|
|
47
70
|
/** The whole path matters too: mssql/server names the engine one component up. */
|
|
48
71
|
function imageParts(image) {
|
|
49
|
-
const
|
|
50
|
-
|
|
72
|
+
const parts = String(image || '').split('@')[0].split('/').filter(Boolean);
|
|
73
|
+
if (parts.length) parts[parts.length - 1] = parts[parts.length - 1].split(':')[0];
|
|
74
|
+
return parts;
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
function say(s) { process.stdout.write(s + '\n'); }
|
|
78
|
+
function warn(s) { process.stderr.write(s + '\n'); }
|
|
54
79
|
|
|
55
80
|
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
56
|
-
function cannotCheck(reason, hint) {
|
|
57
|
-
|
|
58
|
-
|
|
81
|
+
function cannotCheck(reason, hint, stderr) {
|
|
82
|
+
const write = stderr ? warn : say;
|
|
83
|
+
write('⚠️ проверка НЕ выполнена: ' + reason);
|
|
84
|
+
if (hint) write(' ' + hint);
|
|
59
85
|
process.exit(2);
|
|
60
86
|
}
|
|
61
87
|
|
|
@@ -99,7 +125,7 @@ function normalisedConfig(file) {
|
|
|
99
125
|
// env_file: ./x.env -> compose still demanded the project-dir copy
|
|
100
126
|
// All three candidate justifications are project-directory-derived, and the project directory
|
|
101
127
|
// comes from the -f path. Scoped honestly: this is Compose v2+ semantics; v1 differed.
|
|
102
|
-
const r = spawnSync('docker', ['compose', '-f', file, 'config'],
|
|
128
|
+
const r = spawnSync('docker', ['compose', '-f', file, 'config'], SUBPROCESS_OPTIONS);
|
|
103
129
|
if (r.error && r.error.code === 'ENOENT') {
|
|
104
130
|
cannotCheck('docker недоступен на этой машине',
|
|
105
131
|
'без него нормализованный конфиг получить нечем, а разбирать YAML руками — значит ошибиться на короткой форме портов');
|
|
@@ -160,7 +186,10 @@ function parseServices(yaml) {
|
|
|
160
186
|
const svc = raw.match(/^ {2}([A-Za-z0-9_.-]+):\s*$/);
|
|
161
187
|
if (svc) {
|
|
162
188
|
flush();
|
|
163
|
-
cur = {
|
|
189
|
+
cur = {
|
|
190
|
+
name: svc[1], image: '', networkMode: '', ports: [], commandText: '', environmentText: '',
|
|
191
|
+
volumeText: '', hasVolumes: false, hasBuild: false,
|
|
192
|
+
};
|
|
164
193
|
services.push(cur);
|
|
165
194
|
field = ''; fieldIndent = 0;
|
|
166
195
|
continue;
|
|
@@ -175,10 +204,24 @@ function parseServices(yaml) {
|
|
|
175
204
|
fieldIndent = indent;
|
|
176
205
|
if (field === 'image') cur.image = key[2].trim();
|
|
177
206
|
if (field === 'network_mode') cur.networkMode = key[2].trim().replace(/^"|"$/g, '');
|
|
207
|
+
if (field === 'command') cur.commandText += '\n' + key[2].trim();
|
|
208
|
+
if (field === 'environment') cur.environmentText += '\n' + key[2].trim();
|
|
209
|
+
if (field === 'volumes' && key[2].trim() && !/^\[\s*\]$/.test(key[2].trim())) {
|
|
210
|
+
cur.hasVolumes = true;
|
|
211
|
+
cur.volumeText += ' ' + key[2].trim();
|
|
212
|
+
}
|
|
213
|
+
if (field === 'build') cur.hasBuild = true;
|
|
178
214
|
continue;
|
|
179
215
|
}
|
|
180
216
|
if (indent <= fieldIndent && field) { /* still inside the same field's block */ }
|
|
181
217
|
|
|
218
|
+
if (field === 'command') cur.commandText += '\n' + raw.trim();
|
|
219
|
+
if (field === 'environment') cur.environmentText += '\n' + raw.trim();
|
|
220
|
+
if (field === 'volumes' && raw.trim()) {
|
|
221
|
+
cur.hasVolumes = true;
|
|
222
|
+
cur.volumeText += ' ' + raw.trim();
|
|
223
|
+
}
|
|
224
|
+
|
|
182
225
|
// Only inside `ports:` does a sequence item mean anything to this check. Under `command:`,
|
|
183
226
|
// `environment:` or `labels:` a line may contain any text at all, including the word published.
|
|
184
227
|
if (field !== 'ports') continue;
|
|
@@ -199,14 +242,94 @@ function parseServices(yaml) {
|
|
|
199
242
|
return services;
|
|
200
243
|
}
|
|
201
244
|
|
|
202
|
-
const isLoopback = (hostIp) =>
|
|
245
|
+
const isLoopback = (hostIp) => {
|
|
246
|
+
const address = String(hostIp || '').replace(/^\[|\]$/g, '');
|
|
247
|
+
return (net.isIP(address) === 4 && Number(address.split('.')[0]) === 127) || address === '::1';
|
|
248
|
+
};
|
|
249
|
+
const isStorageImage = (image) => imageParts(image).some((part) => STORAGE_NAMES.test(part));
|
|
203
250
|
const isStorage = (svc) =>
|
|
204
|
-
|
|
205
|
-
|| svc.ports.some((p) => STORAGE_PORT.has(Number(p.target)));
|
|
251
|
+
isStorageImage(svc.image) || svc.ports.some((p) => STORAGE_PORT.has(Number(p.target)));
|
|
206
252
|
const isProxy = (svc) => imageParts(svc.image).some((part) => PROXY_NAMES.test(part));
|
|
207
253
|
|
|
208
|
-
function
|
|
209
|
-
const
|
|
254
|
+
function storageExposureMessage(image) {
|
|
255
|
+
const name = imageName(image);
|
|
256
|
+
if (NO_PASSWORD_STORAGE_NAMES.test(name)) {
|
|
257
|
+
return NO_PASSWORD_STORAGE_EXPOSURE;
|
|
258
|
+
}
|
|
259
|
+
if (PASSWORD_STORAGE_NAMES.test(name)) return PASSWORD_STORAGE_EXPOSURE;
|
|
260
|
+
return NEUTRAL_STORAGE_EXPOSURE;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function cacheEngine(image) {
|
|
264
|
+
const name = imageName(image).toLowerCase();
|
|
265
|
+
return name === 'redis' || name === 'memcached' ? name : '';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function semanticValue(value) {
|
|
269
|
+
const text = String(value || '').trim().replace(/^(["'])(.*)\1$/, '$2').trim();
|
|
270
|
+
return text && !/^(?:null|~)$/i.test(text) ? text : '';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hasPasswordEnvironment(environmentText) {
|
|
274
|
+
for (const raw of String(environmentText || '').split('\n')) {
|
|
275
|
+
const line = raw.trim().replace(/^-\s+/, '');
|
|
276
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.*)$/);
|
|
277
|
+
if (!match) continue;
|
|
278
|
+
const key = match[1];
|
|
279
|
+
if (!/(?:PASSWORD|PASSWD)/i.test(key)) continue;
|
|
280
|
+
if (/(?:ALLOW_EMPTY|EMPTY_PASSWORD|PASSWORDLESS|NO_AUTH|AUTH_DISABLED|DISABLE_AUTH)/i.test(key)) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (semanticValue(match[2])) return true;
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function commandTokens(commandText) {
|
|
289
|
+
return String(commandText || '').split('\n').flatMap((raw) => {
|
|
290
|
+
const line = raw.trim().replace(/^-\s+/, '');
|
|
291
|
+
return line ? (line.match(/[^\s=]+=(?:"[^"]*"|'[^']*')|"[^"]*"|'[^']*'|[^\s]+/g) || []) : [];
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function visibleAuth(commandText, environmentText, engine) {
|
|
296
|
+
const tokens = commandTokens(commandText);
|
|
297
|
+
if (tokens.some((token, index) => {
|
|
298
|
+
const assigned = token.match(/^--requirepass=(.*)$/i);
|
|
299
|
+
if (assigned) return Boolean(semanticValue(assigned[1]));
|
|
300
|
+
return token.toLowerCase() === '--requirepass' && tokens[index + 1]
|
|
301
|
+
&& !tokens[index + 1].startsWith('-') && Boolean(semanticValue(tokens[index + 1]));
|
|
302
|
+
})) return true;
|
|
303
|
+
if (engine === 'memcached' && tokens.includes('-S')) return true;
|
|
304
|
+
return hasPasswordEnvironment(environmentText);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function catalogAuthState(svc) {
|
|
308
|
+
const engine = cacheEngine(svc.image);
|
|
309
|
+
if (!engine) return null;
|
|
310
|
+
if (visibleAuth(svc.commandText, svc.environmentText, engine)) return 'authenticated';
|
|
311
|
+
const targets = [...svc.volumeText.matchAll(/(?:^|\s)target:\s*([^\s]+)/gi)]
|
|
312
|
+
.map((match) => match[1].replace(/^"|"$/g, ''));
|
|
313
|
+
const onlyDataMounts = svc.hasVolumes && targets.length > 0
|
|
314
|
+
&& targets.every((target) => target === '/data' || target.startsWith('/data/'));
|
|
315
|
+
const parts = imageParts(svc.image).map((part) => part.toLowerCase());
|
|
316
|
+
const officialLibrary = (parts.length === 2 && parts[0] === 'library' && parts[1] === engine)
|
|
317
|
+
|| (parts.length === 3
|
|
318
|
+
&& /^(?:docker\.io|index\.docker\.io|registry-1\.docker\.io)$/.test(parts[0])
|
|
319
|
+
&& parts[1] === 'library' && parts[2] === engine);
|
|
320
|
+
const derivedImage = parts.length > 1 && !officialLibrary;
|
|
321
|
+
if ((svc.hasVolumes && !onlyDataMounts) || svc.hasBuild || derivedImage) return 'indeterminate';
|
|
322
|
+
return 'unauthenticated';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function printViolations(messages) {
|
|
326
|
+
if (!messages.length) return;
|
|
327
|
+
say('❌ Правило №0 нарушено (.claude/rules/docker-ports.md):');
|
|
328
|
+
for (const message of messages) say(' • ' + message);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function checkCatalog(arg) {
|
|
332
|
+
const file = resolveCompose(arg);
|
|
210
333
|
const services = parseServices(normalisedConfig(file));
|
|
211
334
|
if (!services.length) {
|
|
212
335
|
cannotCheck('в нормализованном конфиге не нашлось ни одного сервиса',
|
|
@@ -214,6 +337,7 @@ function main() {
|
|
|
214
337
|
}
|
|
215
338
|
|
|
216
339
|
const bad = [];
|
|
340
|
+
const unknown = [];
|
|
217
341
|
|
|
218
342
|
for (const svc of services) {
|
|
219
343
|
// network_mode: host publishes everything the container listens on, with no ports: entry at all.
|
|
@@ -221,7 +345,6 @@ function main() {
|
|
|
221
345
|
if (isStorage(svc) && svc.networkMode === 'host') {
|
|
222
346
|
bad.push(svc.name + ': network_mode: host — контейнер слушает прямо на хосте, публикации не '
|
|
223
347
|
+ 'видно, а порт наружу. Хранилищу этот режим не подходит');
|
|
224
|
-
continue;
|
|
225
348
|
}
|
|
226
349
|
if (!isStorage(svc)) continue;
|
|
227
350
|
for (const p of svc.ports) {
|
|
@@ -229,7 +352,20 @@ function main() {
|
|
|
229
352
|
if (isLoopback(p.hostIp)) continue; // the loopback exception IS part of the rule
|
|
230
353
|
bad.push(svc.name + ': порт ' + p.published + ' → ' + (p.target || '?')
|
|
231
354
|
+ (p.hostIp ? ' (host_ip ' + p.hostIp + ')' : ' (без адреса — значит все интерфейсы)')
|
|
232
|
-
+ ' —
|
|
355
|
+
+ ' — ' + storageExposureMessage(svc.image));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const published = svc.ports.filter((p) => p.published);
|
|
359
|
+
const loopbackCacheException = cacheEngine(svc.image) && published.length > 0
|
|
360
|
+
&& published.every((p) => isLoopback(p.hostIp)) && svc.networkMode !== 'host';
|
|
361
|
+
if (!loopbackCacheException) {
|
|
362
|
+
const auth = catalogAuthState(svc);
|
|
363
|
+
if (auth === 'unauthenticated') {
|
|
364
|
+
bad.push(svc.name + ': cache-no-auth — конфиг положительно показывает отсутствие '
|
|
365
|
+
+ 'аутентификации; задайте пароль в command/environment');
|
|
366
|
+
} else if (auth === 'indeterminate') {
|
|
367
|
+
unknown.push(svc.name + ': auth config не виден полностью из-за volume/custom build');
|
|
368
|
+
}
|
|
233
369
|
}
|
|
234
370
|
}
|
|
235
371
|
|
|
@@ -247,18 +383,271 @@ function main() {
|
|
|
247
383
|
}
|
|
248
384
|
}
|
|
249
385
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
for (const
|
|
253
|
-
process.exit(
|
|
386
|
+
printViolations(bad);
|
|
387
|
+
if (unknown.length) {
|
|
388
|
+
for (const reason of unknown) warn('⚠️ проверка НЕ выполнена: ' + reason);
|
|
389
|
+
process.exit(2);
|
|
254
390
|
}
|
|
255
|
-
|
|
391
|
+
if (bad.length) process.exit(1);
|
|
392
|
+
say('✅ каталог ' + file
|
|
393
|
+
+ ' проверен: ни одно хранилище не публикует порт наружу, обходов reverse-proxy нет');
|
|
256
394
|
process.exit(0);
|
|
257
395
|
}
|
|
258
396
|
|
|
397
|
+
const UNSAFE_TERMINAL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
|
|
398
|
+
const UNSAFE_TERMINAL_GLOBAL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g;
|
|
399
|
+
|
|
400
|
+
function terminalText(value, fallback) {
|
|
401
|
+
const text = String(value || '').trim();
|
|
402
|
+
if (!text) return fallback;
|
|
403
|
+
return text.replace(UNSAFE_TERMINAL_GLOBAL, ' ').slice(0, 160);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function trustedLabel(value) {
|
|
407
|
+
const text = String(value || '').trim();
|
|
408
|
+
if (!text || UNSAFE_TERMINAL.test(text)) return 'unknown';
|
|
409
|
+
return text.slice(0, 160);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function ownerText(labels) {
|
|
413
|
+
const source = labels && typeof labels === 'object' ? labels : {};
|
|
414
|
+
const project = trustedLabel(source['com.docker.compose.project']);
|
|
415
|
+
const configFiles = trustedLabel(source['com.docker.compose.project.config_files']);
|
|
416
|
+
if (project === 'unknown') return 'unknown owner';
|
|
417
|
+
return 'owner ' + project + (configFiles === 'unknown' ? '' : ' (' + configFiles + ')');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function dockerCall(args) {
|
|
421
|
+
const result = spawnSync('docker', args, SUBPROCESS_OPTIONS);
|
|
422
|
+
const operation = 'docker ' + args[0];
|
|
423
|
+
if (result.error) {
|
|
424
|
+
if (result.error.code === 'ENOENT') return { ok: false, reason: 'docker недоступен' };
|
|
425
|
+
if (result.error.code === 'ETIMEDOUT') {
|
|
426
|
+
return { ok: false, reason: operation + ' превысил timeout ' + SUBPROCESS_TIMEOUT_MS + 'ms' };
|
|
427
|
+
}
|
|
428
|
+
return { ok: false, reason: operation + ' не удалось запустить' };
|
|
429
|
+
}
|
|
430
|
+
if (result.status !== 0) {
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
reason: operation + ' завершился с кодом ' + result.status,
|
|
434
|
+
stdout: String(result.stdout || ''),
|
|
435
|
+
stderr: String(result.stderr || ''),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
return { ok: true, stdout: String(result.stdout || '') };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function cannotFinding(reason) {
|
|
442
|
+
return { state: 'cannot-check', kind: 'cannot-check', message: reason };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function violationFinding(kind, message) {
|
|
446
|
+
return { state: 'violation', kind, message };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function parseInventory(stdout, findings) {
|
|
450
|
+
const rows = [];
|
|
451
|
+
for (const line of String(stdout || '').split('\n').filter((item) => item.trim())) {
|
|
452
|
+
let row;
|
|
453
|
+
try { row = JSON.parse(line); } catch { findings.push(cannotFinding('docker ps вернул malformed JSON')); continue; }
|
|
454
|
+
const id = String(row.ID || '');
|
|
455
|
+
if (!/^[a-f0-9]{12,64}$/i.test(id) || !String(row.Image || '').trim()) {
|
|
456
|
+
findings.push(cannotFinding('docker ps вернул неполную запись контейнера'));
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
rows.push({ id, image: String(row.Image), name: terminalText(row.Names, id.slice(0, 12)) });
|
|
460
|
+
}
|
|
461
|
+
return rows;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function addExposureFindings(detail, row, owner, findings) {
|
|
465
|
+
const name = terminalText(String(detail.Name || '').replace(/^\//, ''), row.name);
|
|
466
|
+
const networkMode = detail.HostConfig.NetworkMode;
|
|
467
|
+
if (networkMode === 'host') {
|
|
468
|
+
findings.push(violationFinding('host-network', name + ' — ' + owner
|
|
469
|
+
+ ': host-network / network_mode: host публикует хранилище через сеть хоста'));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const ports = detail.NetworkSettings.Ports || {};
|
|
473
|
+
for (const [target, bindings] of Object.entries(ports)) {
|
|
474
|
+
if (bindings === null) continue;
|
|
475
|
+
if (!Array.isArray(bindings) || bindings.length === 0) {
|
|
476
|
+
findings.push(cannotFinding(name + ': docker inspect вернул malformed bindings для '
|
|
477
|
+
+ terminalText(target, '?')));
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
for (const binding of bindings) {
|
|
481
|
+
if (!binding || typeof binding !== 'object' || typeof binding.HostIp !== 'string'
|
|
482
|
+
|| typeof binding.HostPort !== 'string' || !/^\d+$/.test(binding.HostPort)) {
|
|
483
|
+
findings.push(cannotFinding(name + ': docker inspect вернул неполную публикацию '
|
|
484
|
+
+ terminalText(target, '?')));
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const hostIp = binding.HostIp;
|
|
488
|
+
if (isLoopback(hostIp)) continue;
|
|
489
|
+
findings.push(violationFinding('storage-exposure', name + ' — ' + owner + ': storage-exposure '
|
|
490
|
+
+ terminalText(binding.HostPort, '?') + ' → ' + terminalText(target, '?') + ' на '
|
|
491
|
+
+ terminalText(hostIp, 'всех интерфейсах') + '; убрать публикацию или привязать loopback'));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function probeRedis(detail, row, owner, findings, totals) {
|
|
497
|
+
totals.authProbes += 1;
|
|
498
|
+
const probe = dockerCall(['exec', row.id, 'redis-cli', '--raw', 'CONFIG', 'GET', 'requirepass']);
|
|
499
|
+
if (/^NOAUTH\b/im.test(String(probe.stdout || '') + '\n' + String(probe.stderr || ''))) return;
|
|
500
|
+
if (!probe.ok) {
|
|
501
|
+
findings.push(cannotFinding(terminalText(detail.Name, row.name)
|
|
502
|
+
+ ': Redis runtime auth probe не завершён (' + probe.reason + ')'));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const lines = probe.stdout.replace(/\r/g, '').split('\n');
|
|
506
|
+
while (lines.length && lines[lines.length - 1] === '') lines.pop();
|
|
507
|
+
const first = String(lines[0] || '').trim();
|
|
508
|
+
if (/^NOAUTH\b/i.test(first)) return;
|
|
509
|
+
if (first.toLowerCase() === 'requirepass') {
|
|
510
|
+
const hasPassword = lines.slice(1).join('\n').trim().length > 0;
|
|
511
|
+
if (!hasPassword) {
|
|
512
|
+
findings.push(violationFinding('live-cache-no-auth', terminalText(detail.Name, row.name)
|
|
513
|
+
+ ' — ' + owner + ': live-cache-no-auth — живой Redis отвечает без аутентификации'));
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
findings.push(cannotFinding(terminalText(detail.Name, row.name)
|
|
518
|
+
+ ': Redis runtime auth probe вернул нераспознанный ответ'));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function inspectAuth(detail, row, owner, findings, totals) {
|
|
522
|
+
const engine = cacheEngine(row.image);
|
|
523
|
+
if (!engine) return;
|
|
524
|
+
if (engine === 'redis') {
|
|
525
|
+
probeRedis(detail, row, owner, findings, totals);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const commandText = Array.isArray(detail.Config.Cmd)
|
|
529
|
+
? detail.Config.Cmd.join('\n') : String(detail.Config.Cmd || '');
|
|
530
|
+
const environmentText = Array.isArray(detail.Config.Env) ? detail.Config.Env.join('\n') : '';
|
|
531
|
+
if (visibleAuth(commandText, environmentText, engine)) return;
|
|
532
|
+
const onlyDataMounts = detail.Mounts.length > 0 && detail.Mounts.every((mount) => {
|
|
533
|
+
const target = String(mount && mount.Destination || '');
|
|
534
|
+
return target === '/data' || target.startsWith('/data/');
|
|
535
|
+
});
|
|
536
|
+
if (detail.Mounts.length && !onlyDataMounts) {
|
|
537
|
+
findings.push(cannotFinding(terminalText(detail.Name, row.name)
|
|
538
|
+
+ ': Memcached auth config может находиться в mount'));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
findings.push(violationFinding('live-cache-no-auth', terminalText(detail.Name, row.name)
|
|
542
|
+
+ ' — ' + owner + ': live-cache-no-auth — runtime metadata показывает отсутствие аутентификации'));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function validInspect(detail) {
|
|
546
|
+
return detail && typeof detail === 'object'
|
|
547
|
+
&& /^[a-f0-9]{12,64}$/i.test(String(detail.Id || ''))
|
|
548
|
+
&& detail.Config && typeof detail.Config === 'object'
|
|
549
|
+
&& detail.HostConfig && typeof detail.HostConfig.NetworkMode === 'string'
|
|
550
|
+
&& (detail.Config.Cmd === null
|
|
551
|
+
|| (Array.isArray(detail.Config.Cmd)
|
|
552
|
+
&& detail.Config.Cmd.every((item) => typeof item === 'string')))
|
|
553
|
+
&& (detail.Config.Env === null
|
|
554
|
+
|| (Array.isArray(detail.Config.Env)
|
|
555
|
+
&& detail.Config.Env.every((item) => typeof item === 'string')))
|
|
556
|
+
&& detail.NetworkSettings && Object.prototype.hasOwnProperty.call(detail.NetworkSettings, 'Ports')
|
|
557
|
+
&& (detail.NetworkSettings.Ports === null
|
|
558
|
+
|| (typeof detail.NetworkSettings.Ports === 'object'
|
|
559
|
+
&& !Array.isArray(detail.NetworkSettings.Ports)))
|
|
560
|
+
&& Array.isArray(detail.Mounts);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function hasPublishedSurface(detail) {
|
|
564
|
+
if (detail.HostConfig.NetworkMode === 'host') return true;
|
|
565
|
+
return Object.values(detail.NetworkSettings.Ports || {})
|
|
566
|
+
.some((bindings) => Array.isArray(bindings) && bindings.length > 0);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function reportMachine(findings, totals) {
|
|
570
|
+
const unique = [];
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
for (const finding of findings) {
|
|
573
|
+
const key = finding.state + '|' + finding.kind + '|' + finding.message;
|
|
574
|
+
if (!seen.has(key)) { seen.add(key); unique.push(finding); }
|
|
575
|
+
}
|
|
576
|
+
unique.sort((a, b) => a.message.localeCompare(b.message));
|
|
577
|
+
const violations = unique.filter((finding) => finding.state === 'violation');
|
|
578
|
+
const unknown = unique.filter((finding) => finding.state === 'cannot-check');
|
|
579
|
+
printViolations(violations.map((finding) => '[' + finding.kind + '] ' + finding.message));
|
|
580
|
+
for (const finding of unknown) warn('⚠️ проверка НЕ выполнена: ' + finding.message);
|
|
581
|
+
if (unknown.length) process.exit(2);
|
|
582
|
+
if (violations.length) process.exit(1);
|
|
583
|
+
say('✅ снимок текущего Docker-контекста проверен: running_containers=' + totals.running
|
|
584
|
+
+ ', storage_observed=' + totals.storage + ', auth_probes=' + totals.authProbes
|
|
585
|
+
+ '; среди распознанных запущенных хранилищ нарушений не найдено');
|
|
586
|
+
process.exit(0);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function checkMachine() {
|
|
590
|
+
const findings = [];
|
|
591
|
+
const totals = { running: 0, storage: 0, authProbes: 0 };
|
|
592
|
+
const inventoryResult = dockerCall(['ps', '--no-trunc', '--format', '{{json .}}']);
|
|
593
|
+
if (!inventoryResult.ok) reportMachine([cannotFinding(inventoryResult.reason)], totals);
|
|
594
|
+
const inventory = parseInventory(inventoryResult.stdout, findings);
|
|
595
|
+
totals.running = inventory.length;
|
|
596
|
+
const storageRows = inventory.filter((row) => isStorageImage(row.image));
|
|
597
|
+
totals.storage = storageRows.length;
|
|
598
|
+
if (!storageRows.length) reportMachine(findings, totals);
|
|
599
|
+
|
|
600
|
+
const inspectResult = dockerCall(['inspect'].concat(storageRows.map((row) => row.id)));
|
|
601
|
+
if (!inspectResult.ok) {
|
|
602
|
+
findings.push(cannotFinding(inspectResult.reason));
|
|
603
|
+
reportMachine(findings, totals);
|
|
604
|
+
}
|
|
605
|
+
let details;
|
|
606
|
+
try { details = JSON.parse(inspectResult.stdout); } catch { details = null; }
|
|
607
|
+
if (!Array.isArray(details)) {
|
|
608
|
+
findings.push(cannotFinding('docker inspect вернул malformed JSON'));
|
|
609
|
+
reportMachine(findings, totals);
|
|
610
|
+
}
|
|
611
|
+
const byId = new Map();
|
|
612
|
+
for (const detail of details) {
|
|
613
|
+
if (!validInspect(detail)) {
|
|
614
|
+
findings.push(cannotFinding('docker inspect вернул неполную запись контейнера'));
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
byId.set(String(detail.Id), detail);
|
|
618
|
+
}
|
|
619
|
+
for (const row of storageRows) {
|
|
620
|
+
const detail = byId.get(row.id);
|
|
621
|
+
if (!detail) {
|
|
622
|
+
findings.push(cannotFinding(row.name + ': docker inspect не вернул обязательную запись'));
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const owner = ownerText(detail.Config.Labels);
|
|
626
|
+
addExposureFindings(detail, row, owner, findings);
|
|
627
|
+
if (hasPublishedSurface(detail)) inspectAuth(detail, row, owner, findings, totals);
|
|
628
|
+
}
|
|
629
|
+
reportMachine(findings, totals);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function usageError() {
|
|
633
|
+
cannotCheck('неоднозначные аргументы; использование: check-ports.cjs [project|compose] | --machine',
|
|
634
|
+
'режим --machine не принимает дополнительных аргументов', true);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function main() {
|
|
638
|
+
const args = process.argv.slice(2);
|
|
639
|
+
if (args[0] === '--machine') {
|
|
640
|
+
if (args.length !== 1) usageError();
|
|
641
|
+
return checkMachine();
|
|
642
|
+
}
|
|
643
|
+
if (args.length > 1 || (args[0] && args[0].startsWith('-'))) usageError();
|
|
644
|
+
return checkCatalog(args[0]);
|
|
645
|
+
}
|
|
646
|
+
|
|
259
647
|
try {
|
|
260
648
|
main();
|
|
261
649
|
} catch (err) {
|
|
262
650
|
// Even an unexpected failure must not read as "clean".
|
|
263
|
-
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err)
|
|
651
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err), null,
|
|
652
|
+
process.argv[2] === '--machine');
|
|
264
653
|
}
|