@dzhechkov/p-replicator 1.10.4 → 1.13.1

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 (136) hide show
  1. package/.dz-manifest.json +312 -76
  2. package/CHANGELOG.md +232 -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/05_architecture.md +1 -1
  9. package/README/eng/README.md +2 -1
  10. package/README/ru/01_quickstart.md +3 -3
  11. package/README/ru/02_user_guide.md +1 -1
  12. package/README/ru/03_admin_guide.md +2 -2
  13. package/README/ru/04_api_reference.md +11 -5
  14. package/README/ru/05_architecture.md +1 -1
  15. package/README/ru/README.md +2 -1
  16. package/README/ru/html/index.html +9 -9
  17. package/README.md +278 -39
  18. package/package.json +5 -4
  19. package/sbom.json +665 -75
  20. package/scripts/check-pipeline-gaps.sh +413 -0
  21. package/src/commands/doctor.js +94 -4
  22. package/src/commands/init.js +1 -1
  23. package/src/rule-components.json +15 -0
  24. package/src/utils.js +35 -11
  25. package/templates/.claude/agents/harvest-coordinator.md +10 -1
  26. package/templates/.claude/agents/product-discoverer.md +38 -0
  27. package/templates/.claude/agents/replicate-coordinator.md +11 -1
  28. package/templates/.claude/commands/feature.md +81 -9
  29. package/templates/.claude/commands/go.md +9 -0
  30. package/templates/.claude/commands/harvest.md +39 -3
  31. package/templates/.claude/commands/myinsights.md +21 -26
  32. package/templates/.claude/commands/replicate.md +171 -37
  33. package/templates/.claude/commands/start.md +29 -0
  34. package/templates/.claude/hooks/capture-source-path.cjs +795 -0
  35. package/templates/.claude/hooks/check-canon.cjs +493 -0
  36. package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
  37. package/templates/.claude/hooks/check-external-deps.cjs +288 -0
  38. package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
  39. package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
  40. package/templates/.claude/hooks/check-job-contract.cjs +501 -0
  41. package/templates/.claude/hooks/check-look-origin.cjs +240 -0
  42. package/templates/.claude/hooks/check-look-trace.cjs +385 -0
  43. package/templates/.claude/hooks/check-metric-source.cjs +296 -0
  44. package/templates/.claude/hooks/check-model-cost.cjs +470 -0
  45. package/templates/.claude/hooks/check-ports.cjs +434 -24
  46. package/templates/.claude/hooks/check-source-version.cjs +312 -0
  47. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  48. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  49. package/templates/.claude/hooks/session-insights.cjs +158 -25
  50. package/templates/.claude/hooks/statusline.cjs +2 -2
  51. package/templates/.claude/hooks/write-insight.cjs +253 -0
  52. package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
  53. package/templates/.claude/rules/docker-ports.md +41 -19
  54. package/templates/.claude/rules/embeddable-widget.md +73 -0
  55. package/templates/.claude/rules/feature-lifecycle.md +13 -3
  56. package/templates/.claude/rules/honest-configuration.md +54 -0
  57. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  58. package/templates/.claude/rules/insights-capture.md +10 -5
  59. package/templates/.claude/rules/long-running-job.md +73 -0
  60. package/templates/.claude/rules/model-call-cost.md +85 -0
  61. package/templates/.claude/rules/replicate-pipeline.md +123 -52
  62. package/templates/.claude/rules/skill-interface-protocol.md +1 -0
  63. package/templates/.claude/rules/swarm-file-evidence.md +46 -0
  64. package/templates/.claude/settings.json +13 -1
  65. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  66. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  67. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  68. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  69. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  70. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  71. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  72. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  73. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  74. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  75. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  76. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  77. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  78. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  79. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  80. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  81. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  82. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  83. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  84. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  85. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  86. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  87. package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
  88. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  89. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  90. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  91. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  92. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  93. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  94. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
  95. package/tests/e2e/lifecycle.test.js +55 -9
  96. package/tests/e2e/packed-insights-writer.test.js +308 -0
  97. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
  98. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
  99. package/tests/snapshot/baseline.json +72 -46
  100. package/tests/snapshot/templates.test.js +47 -0
  101. package/tests/unit/absence-is-not-emptiness.test.js +15 -1
  102. package/tests/unit/capture-source-path.test.js +492 -0
  103. package/tests/unit/check-canon.test.js +403 -0
  104. package/tests/unit/check-embed-contract.test.js +422 -0
  105. package/tests/unit/check-external-deps.test.js +363 -0
  106. package/tests/unit/check-file-ownership.test.js +388 -0
  107. package/tests/unit/check-handoff-manifest.test.js +410 -0
  108. package/tests/unit/check-job-contract.test.js +514 -0
  109. package/tests/unit/check-look-origin.test.js +180 -0
  110. package/tests/unit/check-look-trace.test.js +420 -0
  111. package/tests/unit/check-metric-source.test.js +325 -0
  112. package/tests/unit/check-model-cost.test.js +425 -0
  113. package/tests/unit/check-pipeline-gaps.test.js +94 -0
  114. package/tests/unit/check-ports.test.js +773 -2
  115. package/tests/unit/check-source-version.test.js +344 -0
  116. package/tests/unit/check-swarm-receipts.test.js +231 -0
  117. package/tests/unit/check-webhook-contract.test.js +536 -0
  118. package/tests/unit/db-port-rule.test.js +43 -6
  119. package/tests/unit/detection-ladder-contract.test.js +302 -0
  120. package/tests/unit/detection-ladder-registry.test.js +52 -0
  121. package/tests/unit/doctor-insight-flow.test.js +315 -0
  122. package/tests/unit/external-dependency-check.test.js +19 -19
  123. package/tests/unit/generator-swarm-contract.test.js +287 -0
  124. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  125. package/tests/unit/honest-failure-rules.test.js +574 -0
  126. package/tests/unit/hooks-project-anchored.test.js +67 -3
  127. package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
  128. package/tests/unit/insights-dz-delegation.test.js +197 -0
  129. package/tests/unit/insights-writer.test.js +285 -0
  130. package/tests/unit/look-phase-contract.test.js +231 -0
  131. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  132. package/tests/unit/quote-provenance.test.js +122 -0
  133. package/tests/unit/shipped-suite-context.test.js +3 -1
  134. package/tests/unit/traceability-machine-ids.test.js +413 -0
  135. package/tests/unit/traceability-negative-fixture.test.js +322 -0
  136. package/tests/unit/utils.test.js +40 -2
@@ -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,19 +25,41 @@
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
33
43
  // called `rediscommander/redis-commander` storage (it is a web UI), and missed
34
44
  // `mcr.microsoft.com/mssql/server` on its standard 1433 entirely. So: take the image NAME —
35
45
  // the last path component, without registry, tag or digest — and anchor to it.
36
- 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;
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|qdrant)$/i;
37
47
  const STORAGE_PORT = new Set([5432, 3306, 27017, 6379, 9200, 9300, 5672, 11211, 9000, 8123,
38
- 1433, 9042, 7687, 8086, 2379, 5984]);
39
- const PROXY_NAMES = /^(caddy|nginx|traefik|haproxy|envoy)$/i;
48
+ 1433, 9042, 7687, 8086, 2379, 5984, 6333]);
49
+ const PASSWORD_STORAGE_NAMES = /^(postgres|mysql|mariadb|mongo)$/i;
50
+ const NO_PASSWORD_STORAGE_NAMES = /^(redis|valkey|keydb|memcached)$/i;
51
+ const PROXY_NAMES = /^(caddy|nginx|traefik|haproxy|envoy|openresty)$/i;
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';
40
63
 
41
64
  /** `mcr.microsoft.com/mssql/server:2022` → `server`; `postgres:16` → `postgres`. */
42
65
  function imageName(image) {
@@ -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,114 @@ 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 unrecognizedServiceText(svc) {
255
+ const publicPorts = svc.ports
256
+ .filter((port) => port.published && !isLoopback(port.hostIp))
257
+ .map((port) => port.published);
258
+ const surfaces = [];
259
+ if (svc.networkMode === 'host') surfaces.push('network_mode: host');
260
+ if (publicPorts.length === 1) surfaces.push('порт ' + publicPorts[0] + ' наружу');
261
+ if (publicPorts.length > 1) surfaces.push('порты ' + publicPorts.join(', ') + ' наружу');
262
+ return svc.name + (surfaces.length ? ' (' + surfaces.join('; ') + ')' : '');
263
+ }
264
+
265
+ function catalogScopeText(services, storage, proxies) {
266
+ const unrecognized = services.filter((svc) => !isStorage(svc) && !isProxy(svc));
267
+ const counts = 'из ' + services.length + ' сервисов распознано хранилищ ' + storage.length
268
+ + ', reverse-proxy ' + proxies.length;
269
+ if (!unrecognized.length) return counts + '; все сервисы распознаны в этой области проверки';
270
+ return counts + '; НЕ распознаны и не проверялись: '
271
+ + unrecognized.map(unrecognizedServiceText).join(', ');
272
+ }
273
+
274
+ function storageExposureMessage(image) {
275
+ const name = imageName(image);
276
+ if (NO_PASSWORD_STORAGE_NAMES.test(name)) {
277
+ return NO_PASSWORD_STORAGE_EXPOSURE;
278
+ }
279
+ if (PASSWORD_STORAGE_NAMES.test(name)) return PASSWORD_STORAGE_EXPOSURE;
280
+ return NEUTRAL_STORAGE_EXPOSURE;
281
+ }
282
+
283
+ function cacheEngine(image) {
284
+ const name = imageName(image).toLowerCase();
285
+ return name === 'redis' || name === 'memcached' ? name : '';
286
+ }
287
+
288
+ function semanticValue(value) {
289
+ const text = String(value || '').trim().replace(/^(["'])(.*)\1$/, '$2').trim();
290
+ return text && !/^(?:null|~)$/i.test(text) ? text : '';
291
+ }
292
+
293
+ function hasPasswordEnvironment(environmentText) {
294
+ for (const raw of String(environmentText || '').split('\n')) {
295
+ const line = raw.trim().replace(/^-\s+/, '');
296
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.*)$/);
297
+ if (!match) continue;
298
+ const key = match[1];
299
+ if (!/(?:PASSWORD|PASSWD)/i.test(key)) continue;
300
+ if (/(?:ALLOW_EMPTY|EMPTY_PASSWORD|PASSWORDLESS|NO_AUTH|AUTH_DISABLED|DISABLE_AUTH)/i.test(key)) {
301
+ continue;
302
+ }
303
+ if (semanticValue(match[2])) return true;
304
+ }
305
+ return false;
306
+ }
307
+
308
+ function commandTokens(commandText) {
309
+ return String(commandText || '').split('\n').flatMap((raw) => {
310
+ const line = raw.trim().replace(/^-\s+/, '');
311
+ return line ? (line.match(/[^\s=]+=(?:"[^"]*"|'[^']*')|"[^"]*"|'[^']*'|[^\s]+/g) || []) : [];
312
+ });
313
+ }
314
+
315
+ function visibleAuth(commandText, environmentText, engine) {
316
+ const tokens = commandTokens(commandText);
317
+ if (tokens.some((token, index) => {
318
+ const assigned = token.match(/^--requirepass=(.*)$/i);
319
+ if (assigned) return Boolean(semanticValue(assigned[1]));
320
+ return token.toLowerCase() === '--requirepass' && tokens[index + 1]
321
+ && !tokens[index + 1].startsWith('-') && Boolean(semanticValue(tokens[index + 1]));
322
+ })) return true;
323
+ if (engine === 'memcached' && tokens.includes('-S')) return true;
324
+ return hasPasswordEnvironment(environmentText);
325
+ }
326
+
327
+ function catalogAuthState(svc) {
328
+ const engine = cacheEngine(svc.image);
329
+ if (!engine) return null;
330
+ if (visibleAuth(svc.commandText, svc.environmentText, engine)) return 'authenticated';
331
+ const targets = [...svc.volumeText.matchAll(/(?:^|\s)target:\s*([^\s]+)/gi)]
332
+ .map((match) => match[1].replace(/^"|"$/g, ''));
333
+ const onlyDataMounts = svc.hasVolumes && targets.length > 0
334
+ && targets.every((target) => target === '/data' || target.startsWith('/data/'));
335
+ const parts = imageParts(svc.image).map((part) => part.toLowerCase());
336
+ const officialLibrary = (parts.length === 2 && parts[0] === 'library' && parts[1] === engine)
337
+ || (parts.length === 3
338
+ && /^(?:docker\.io|index\.docker\.io|registry-1\.docker\.io)$/.test(parts[0])
339
+ && parts[1] === 'library' && parts[2] === engine);
340
+ const derivedImage = parts.length > 1 && !officialLibrary;
341
+ if ((svc.hasVolumes && !onlyDataMounts) || svc.hasBuild || derivedImage) return 'indeterminate';
342
+ return 'unauthenticated';
343
+ }
344
+
345
+ function printViolations(messages) {
346
+ if (!messages.length) return;
347
+ say('❌ Правило №0 нарушено (.claude/rules/docker-ports.md):');
348
+ for (const message of messages) say(' • ' + message);
349
+ }
350
+
351
+ function checkCatalog(arg) {
352
+ const file = resolveCompose(arg);
210
353
  const services = parseServices(normalisedConfig(file));
211
354
  if (!services.length) {
212
355
  cannotCheck('в нормализованном конфиге не нашлось ни одного сервиса',
@@ -214,6 +357,9 @@ function main() {
214
357
  }
215
358
 
216
359
  const bad = [];
360
+ const unknown = [];
361
+ const storage = services.filter(isStorage);
362
+ const proxies = services.filter(isProxy);
217
363
 
218
364
  for (const svc of services) {
219
365
  // network_mode: host publishes everything the container listens on, with no ports: entry at all.
@@ -221,7 +367,6 @@ function main() {
221
367
  if (isStorage(svc) && svc.networkMode === 'host') {
222
368
  bad.push(svc.name + ': network_mode: host — контейнер слушает прямо на хосте, публикации не '
223
369
  + 'видно, а порт наружу. Хранилищу этот режим не подходит');
224
- continue;
225
370
  }
226
371
  if (!isStorage(svc)) continue;
227
372
  for (const p of svc.ports) {
@@ -229,13 +374,25 @@ function main() {
229
374
  if (isLoopback(p.hostIp)) continue; // the loopback exception IS part of the rule
230
375
  bad.push(svc.name + ': порт ' + p.published + ' → ' + (p.target || '?')
231
376
  + (p.hostIp ? ' (host_ip ' + p.hostIp + ')' : ' (без адреса — значит все интерфейсы)')
232
- + ' — хранилище опубликовано наружу. Убрать ports: целиком, либо привязать к 127.0.0.1');
377
+ + ' — ' + storageExposureMessage(svc.image));
378
+ }
379
+
380
+ const published = svc.ports.filter((p) => p.published);
381
+ const loopbackCacheException = cacheEngine(svc.image) && published.length > 0
382
+ && published.every((p) => isLoopback(p.hostIp)) && svc.networkMode !== 'host';
383
+ if (!loopbackCacheException) {
384
+ const auth = catalogAuthState(svc);
385
+ if (auth === 'unauthenticated') {
386
+ bad.push(svc.name + ': cache-no-auth — конфиг положительно показывает отсутствие '
387
+ + 'аутентификации; задайте пароль в command/environment');
388
+ } else if (auth === 'indeterminate') {
389
+ unknown.push(svc.name + ': auth config не виден полностью из-за volume/custom build');
390
+ }
233
391
  }
234
392
  }
235
393
 
236
394
  // Behind a reverse-proxy the proxy is the only door; anything published beside it can be reached
237
395
  // directly, bypassing everything the proxy guarantees.
238
- const proxies = services.filter(isProxy);
239
396
  if (proxies.length) {
240
397
  for (const svc of services) {
241
398
  if (isProxy(svc)) continue;
@@ -247,18 +404,271 @@ function main() {
247
404
  }
248
405
  }
249
406
 
250
- if (bad.length) {
251
- say('❌ Правило №0 нарушено (.claude/rules/docker-ports.md):');
252
- for (const b of bad) say(' ' + b);
253
- process.exit(1);
407
+ printViolations(bad);
408
+ if (unknown.length) {
409
+ for (const reason of unknown) warn('⚠️ проверка НЕ выполнена: ' + reason);
410
+ process.exit(2);
254
411
  }
255
- say('✅ ни одно хранилище не публикует порт наружу, обходов reverse-proxy нет');
412
+ if (bad.length) process.exit(1);
413
+ say('✅ каталог ' + file + ' проверен в заявленной области: '
414
+ + catalogScopeText(services, storage, proxies));
256
415
  process.exit(0);
257
416
  }
258
417
 
418
+ const UNSAFE_TERMINAL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
419
+ const UNSAFE_TERMINAL_GLOBAL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g;
420
+
421
+ function terminalText(value, fallback) {
422
+ const text = String(value || '').trim();
423
+ if (!text) return fallback;
424
+ return text.replace(UNSAFE_TERMINAL_GLOBAL, ' ').slice(0, 160);
425
+ }
426
+
427
+ function trustedLabel(value) {
428
+ const text = String(value || '').trim();
429
+ if (!text || UNSAFE_TERMINAL.test(text)) return 'unknown';
430
+ return text.slice(0, 160);
431
+ }
432
+
433
+ function ownerText(labels) {
434
+ const source = labels && typeof labels === 'object' ? labels : {};
435
+ const project = trustedLabel(source['com.docker.compose.project']);
436
+ const configFiles = trustedLabel(source['com.docker.compose.project.config_files']);
437
+ if (project === 'unknown') return 'unknown owner';
438
+ return 'owner ' + project + (configFiles === 'unknown' ? '' : ' (' + configFiles + ')');
439
+ }
440
+
441
+ function dockerCall(args) {
442
+ const result = spawnSync('docker', args, SUBPROCESS_OPTIONS);
443
+ const operation = 'docker ' + args[0];
444
+ if (result.error) {
445
+ if (result.error.code === 'ENOENT') return { ok: false, reason: 'docker недоступен' };
446
+ if (result.error.code === 'ETIMEDOUT') {
447
+ return { ok: false, reason: operation + ' превысил timeout ' + SUBPROCESS_TIMEOUT_MS + 'ms' };
448
+ }
449
+ return { ok: false, reason: operation + ' не удалось запустить' };
450
+ }
451
+ if (result.status !== 0) {
452
+ return {
453
+ ok: false,
454
+ reason: operation + ' завершился с кодом ' + result.status,
455
+ stdout: String(result.stdout || ''),
456
+ stderr: String(result.stderr || ''),
457
+ };
458
+ }
459
+ return { ok: true, stdout: String(result.stdout || '') };
460
+ }
461
+
462
+ function cannotFinding(reason) {
463
+ return { state: 'cannot-check', kind: 'cannot-check', message: reason };
464
+ }
465
+
466
+ function violationFinding(kind, message) {
467
+ return { state: 'violation', kind, message };
468
+ }
469
+
470
+ function parseInventory(stdout, findings) {
471
+ const rows = [];
472
+ for (const line of String(stdout || '').split('\n').filter((item) => item.trim())) {
473
+ let row;
474
+ try { row = JSON.parse(line); } catch { findings.push(cannotFinding('docker ps вернул malformed JSON')); continue; }
475
+ const id = String(row.ID || '');
476
+ if (!/^[a-f0-9]{12,64}$/i.test(id) || !String(row.Image || '').trim()) {
477
+ findings.push(cannotFinding('docker ps вернул неполную запись контейнера'));
478
+ continue;
479
+ }
480
+ rows.push({ id, image: String(row.Image), name: terminalText(row.Names, id.slice(0, 12)) });
481
+ }
482
+ return rows;
483
+ }
484
+
485
+ function addExposureFindings(detail, row, owner, findings) {
486
+ const name = terminalText(String(detail.Name || '').replace(/^\//, ''), row.name);
487
+ const networkMode = detail.HostConfig.NetworkMode;
488
+ if (networkMode === 'host') {
489
+ findings.push(violationFinding('host-network', name + ' — ' + owner
490
+ + ': host-network / network_mode: host публикует хранилище через сеть хоста'));
491
+ return;
492
+ }
493
+ const ports = detail.NetworkSettings.Ports || {};
494
+ for (const [target, bindings] of Object.entries(ports)) {
495
+ if (bindings === null) continue;
496
+ if (!Array.isArray(bindings) || bindings.length === 0) {
497
+ findings.push(cannotFinding(name + ': docker inspect вернул malformed bindings для '
498
+ + terminalText(target, '?')));
499
+ continue;
500
+ }
501
+ for (const binding of bindings) {
502
+ if (!binding || typeof binding !== 'object' || typeof binding.HostIp !== 'string'
503
+ || typeof binding.HostPort !== 'string' || !/^\d+$/.test(binding.HostPort)) {
504
+ findings.push(cannotFinding(name + ': docker inspect вернул неполную публикацию '
505
+ + terminalText(target, '?')));
506
+ continue;
507
+ }
508
+ const hostIp = binding.HostIp;
509
+ if (isLoopback(hostIp)) continue;
510
+ findings.push(violationFinding('storage-exposure', name + ' — ' + owner + ': storage-exposure '
511
+ + terminalText(binding.HostPort, '?') + ' → ' + terminalText(target, '?') + ' на '
512
+ + terminalText(hostIp, 'всех интерфейсах') + '; убрать публикацию или привязать loopback'));
513
+ }
514
+ }
515
+ }
516
+
517
+ function probeRedis(detail, row, owner, findings, totals) {
518
+ totals.authProbes += 1;
519
+ const probe = dockerCall(['exec', row.id, 'redis-cli', '--raw', 'CONFIG', 'GET', 'requirepass']);
520
+ if (/^NOAUTH\b/im.test(String(probe.stdout || '') + '\n' + String(probe.stderr || ''))) return;
521
+ if (!probe.ok) {
522
+ findings.push(cannotFinding(terminalText(detail.Name, row.name)
523
+ + ': Redis runtime auth probe не завершён (' + probe.reason + ')'));
524
+ return;
525
+ }
526
+ const lines = probe.stdout.replace(/\r/g, '').split('\n');
527
+ while (lines.length && lines[lines.length - 1] === '') lines.pop();
528
+ const first = String(lines[0] || '').trim();
529
+ if (/^NOAUTH\b/i.test(first)) return;
530
+ if (first.toLowerCase() === 'requirepass') {
531
+ const hasPassword = lines.slice(1).join('\n').trim().length > 0;
532
+ if (!hasPassword) {
533
+ findings.push(violationFinding('live-cache-no-auth', terminalText(detail.Name, row.name)
534
+ + ' — ' + owner + ': live-cache-no-auth — живой Redis отвечает без аутентификации'));
535
+ }
536
+ return;
537
+ }
538
+ findings.push(cannotFinding(terminalText(detail.Name, row.name)
539
+ + ': Redis runtime auth probe вернул нераспознанный ответ'));
540
+ }
541
+
542
+ function inspectAuth(detail, row, owner, findings, totals) {
543
+ const engine = cacheEngine(row.image);
544
+ if (!engine) return;
545
+ if (engine === 'redis') {
546
+ probeRedis(detail, row, owner, findings, totals);
547
+ return;
548
+ }
549
+ const commandText = Array.isArray(detail.Config.Cmd)
550
+ ? detail.Config.Cmd.join('\n') : String(detail.Config.Cmd || '');
551
+ const environmentText = Array.isArray(detail.Config.Env) ? detail.Config.Env.join('\n') : '';
552
+ if (visibleAuth(commandText, environmentText, engine)) return;
553
+ const onlyDataMounts = detail.Mounts.length > 0 && detail.Mounts.every((mount) => {
554
+ const target = String(mount && mount.Destination || '');
555
+ return target === '/data' || target.startsWith('/data/');
556
+ });
557
+ if (detail.Mounts.length && !onlyDataMounts) {
558
+ findings.push(cannotFinding(terminalText(detail.Name, row.name)
559
+ + ': Memcached auth config может находиться в mount'));
560
+ return;
561
+ }
562
+ findings.push(violationFinding('live-cache-no-auth', terminalText(detail.Name, row.name)
563
+ + ' — ' + owner + ': live-cache-no-auth — runtime metadata показывает отсутствие аутентификации'));
564
+ }
565
+
566
+ function validInspect(detail) {
567
+ return detail && typeof detail === 'object'
568
+ && /^[a-f0-9]{12,64}$/i.test(String(detail.Id || ''))
569
+ && detail.Config && typeof detail.Config === 'object'
570
+ && detail.HostConfig && typeof detail.HostConfig.NetworkMode === 'string'
571
+ && (detail.Config.Cmd === null
572
+ || (Array.isArray(detail.Config.Cmd)
573
+ && detail.Config.Cmd.every((item) => typeof item === 'string')))
574
+ && (detail.Config.Env === null
575
+ || (Array.isArray(detail.Config.Env)
576
+ && detail.Config.Env.every((item) => typeof item === 'string')))
577
+ && detail.NetworkSettings && Object.prototype.hasOwnProperty.call(detail.NetworkSettings, 'Ports')
578
+ && (detail.NetworkSettings.Ports === null
579
+ || (typeof detail.NetworkSettings.Ports === 'object'
580
+ && !Array.isArray(detail.NetworkSettings.Ports)))
581
+ && Array.isArray(detail.Mounts);
582
+ }
583
+
584
+ function hasPublishedSurface(detail) {
585
+ if (detail.HostConfig.NetworkMode === 'host') return true;
586
+ return Object.values(detail.NetworkSettings.Ports || {})
587
+ .some((bindings) => Array.isArray(bindings) && bindings.length > 0);
588
+ }
589
+
590
+ function reportMachine(findings, totals) {
591
+ const unique = [];
592
+ const seen = new Set();
593
+ for (const finding of findings) {
594
+ const key = finding.state + '|' + finding.kind + '|' + finding.message;
595
+ if (!seen.has(key)) { seen.add(key); unique.push(finding); }
596
+ }
597
+ unique.sort((a, b) => a.message.localeCompare(b.message));
598
+ const violations = unique.filter((finding) => finding.state === 'violation');
599
+ const unknown = unique.filter((finding) => finding.state === 'cannot-check');
600
+ printViolations(violations.map((finding) => '[' + finding.kind + '] ' + finding.message));
601
+ for (const finding of unknown) warn('⚠️ проверка НЕ выполнена: ' + finding.message);
602
+ if (unknown.length) process.exit(2);
603
+ if (violations.length) process.exit(1);
604
+ say('✅ снимок текущего Docker-контекста проверен: running_containers=' + totals.running
605
+ + ', storage_observed=' + totals.storage + ', auth_probes=' + totals.authProbes
606
+ + '; среди распознанных запущенных хранилищ нарушений не найдено');
607
+ process.exit(0);
608
+ }
609
+
610
+ function checkMachine() {
611
+ const findings = [];
612
+ const totals = { running: 0, storage: 0, authProbes: 0 };
613
+ const inventoryResult = dockerCall(['ps', '--no-trunc', '--format', '{{json .}}']);
614
+ if (!inventoryResult.ok) reportMachine([cannotFinding(inventoryResult.reason)], totals);
615
+ const inventory = parseInventory(inventoryResult.stdout, findings);
616
+ totals.running = inventory.length;
617
+ const storageRows = inventory.filter((row) => isStorageImage(row.image));
618
+ totals.storage = storageRows.length;
619
+ if (!storageRows.length) reportMachine(findings, totals);
620
+
621
+ const inspectResult = dockerCall(['inspect'].concat(storageRows.map((row) => row.id)));
622
+ if (!inspectResult.ok) {
623
+ findings.push(cannotFinding(inspectResult.reason));
624
+ reportMachine(findings, totals);
625
+ }
626
+ let details;
627
+ try { details = JSON.parse(inspectResult.stdout); } catch { details = null; }
628
+ if (!Array.isArray(details)) {
629
+ findings.push(cannotFinding('docker inspect вернул malformed JSON'));
630
+ reportMachine(findings, totals);
631
+ }
632
+ const byId = new Map();
633
+ for (const detail of details) {
634
+ if (!validInspect(detail)) {
635
+ findings.push(cannotFinding('docker inspect вернул неполную запись контейнера'));
636
+ continue;
637
+ }
638
+ byId.set(String(detail.Id), detail);
639
+ }
640
+ for (const row of storageRows) {
641
+ const detail = byId.get(row.id);
642
+ if (!detail) {
643
+ findings.push(cannotFinding(row.name + ': docker inspect не вернул обязательную запись'));
644
+ continue;
645
+ }
646
+ const owner = ownerText(detail.Config.Labels);
647
+ addExposureFindings(detail, row, owner, findings);
648
+ if (hasPublishedSurface(detail)) inspectAuth(detail, row, owner, findings, totals);
649
+ }
650
+ reportMachine(findings, totals);
651
+ }
652
+
653
+ function usageError() {
654
+ cannotCheck('неоднозначные аргументы; использование: check-ports.cjs [project|compose] | --machine',
655
+ 'режим --machine не принимает дополнительных аргументов', true);
656
+ }
657
+
658
+ function main() {
659
+ const args = process.argv.slice(2);
660
+ if (args[0] === '--machine') {
661
+ if (args.length !== 1) usageError();
662
+ return checkMachine();
663
+ }
664
+ if (args.length > 1 || (args[0] && args[0].startsWith('-'))) usageError();
665
+ return checkCatalog(args[0]);
666
+ }
667
+
259
668
  try {
260
669
  main();
261
670
  } catch (err) {
262
671
  // Even an unexpected failure must not read as "clean".
263
- cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
672
+ cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err), null,
673
+ process.argv[2] === '--machine');
264
674
  }