@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.
Files changed (68) hide show
  1. package/.dz-manifest.json +119 -47
  2. package/CHANGELOG.md +85 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +3 -3
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/04_api_reference.md +11 -5
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +3 -3
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/04_api_reference.md +11 -5
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +154 -38
  16. package/bin/cli.js +0 -0
  17. package/package.json +15 -13
  18. package/sbom.json +226 -46
  19. package/scripts/check-pipeline-gaps.sh +413 -0
  20. package/src/commands/doctor.js +94 -4
  21. package/src/rule-components.json +11 -0
  22. package/src/utils.js +3 -8
  23. package/templates/.claude/agents/harvest-coordinator.md +10 -1
  24. package/templates/.claude/commands/feature.md +57 -9
  25. package/templates/.claude/commands/go.md +11 -0
  26. package/templates/.claude/commands/harvest.md +41 -3
  27. package/templates/.claude/commands/myinsights.md +21 -26
  28. package/templates/.claude/commands/replicate.md +10 -1
  29. package/templates/.claude/commands/start.md +8 -0
  30. package/templates/.claude/hooks/check-ports.cjs +409 -20
  31. package/templates/.claude/hooks/session-insights.cjs +158 -25
  32. package/templates/.claude/hooks/statusline.cjs +2 -2
  33. package/templates/.claude/hooks/write-insight.cjs +253 -0
  34. package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
  35. package/templates/.claude/rules/docker-ports.md +41 -19
  36. package/templates/.claude/rules/feature-lifecycle.md +14 -3
  37. package/templates/.claude/rules/honest-configuration.md +54 -0
  38. package/templates/.claude/rules/insights-capture.md +10 -5
  39. package/templates/.claude/rules/replicate-pipeline.md +4 -2
  40. package/templates/.claude/rules/skill-interface-protocol.md +1 -0
  41. package/templates/.claude/rules/swarm-file-evidence.md +46 -0
  42. package/templates/.claude/settings.json +13 -1
  43. package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
  44. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +86 -16
  45. package/tests/e2e/lifecycle.test.js +55 -9
  46. package/tests/e2e/packed-insights-writer.test.js +308 -0
  47. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
  48. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
  49. package/tests/snapshot/baseline.json +24 -20
  50. package/tests/snapshot/templates.test.js +47 -0
  51. package/tests/unit/absence-is-not-emptiness.test.js +15 -1
  52. package/tests/unit/check-pipeline-gaps.test.js +94 -0
  53. package/tests/unit/check-ports.test.js +729 -2
  54. package/tests/unit/db-port-rule.test.js +36 -5
  55. package/tests/unit/detection-ladder-contract.test.js +302 -0
  56. package/tests/unit/detection-ladder-registry.test.js +52 -0
  57. package/tests/unit/doctor-insight-flow.test.js +315 -0
  58. package/tests/unit/external-dependency-check.test.js +19 -19
  59. package/tests/unit/honest-failure-rules.test.js +492 -0
  60. package/tests/unit/hooks-project-anchored.test.js +67 -3
  61. package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
  62. package/tests/unit/insights-dz-delegation.test.js +197 -0
  63. package/tests/unit/insights-writer.test.js +285 -0
  64. package/tests/unit/shipped-suite-context.test.js +3 -1
  65. package/tests/unit/traceability-machine-ids.test.js +413 -0
  66. package/tests/unit/traceability-negative-fixture.test.js +322 -0
  67. package/tests/unit/utils.test.js +3 -2
  68. 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 noDigest = String(image || '').split('@')[0].split(':')[0];
50
- return noDigest.split('/').filter(Boolean);
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
- say('⚠️ проверка НЕ выполнена: ' + reason);
58
- if (hint) say(' ' + hint);
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'], { encoding: 'utf8' });
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 = { name: svc[1], image: '', networkMode: '', ports: [] };
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) => /^127\./.test(hostIp) || hostIp === '::1' || hostIp === '[::1]';
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
- imageParts(svc.image).some((part) => STORAGE_NAMES.test(part))
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 main() {
209
- const file = resolveCompose(process.argv[2]);
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
- + ' — хранилище опубликовано наружу. Убрать ports: целиком, либо привязать к 127.0.0.1');
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
- if (bad.length) {
251
- say('❌ Правило №0 нарушено (.claude/rules/docker-ports.md):');
252
- for (const b of bad) say(' ' + b);
253
- process.exit(1);
386
+ printViolations(bad);
387
+ if (unknown.length) {
388
+ for (const reason of unknown) warn('⚠️ проверка НЕ выполнена: ' + reason);
389
+ process.exit(2);
254
390
  }
255
- say('✅ ни одно хранилище не публикует порт наружу, обходов reverse-proxy нет');
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
  }