@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.
- package/.dz-manifest.json +312 -76
- package/CHANGELOG.md +232 -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/05_architecture.md +1 -1
- package/README/eng/README.md +2 -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/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +9 -9
- package/README.md +278 -39
- package/package.json +5 -4
- package/sbom.json +665 -75
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +15 -0
- package/src/utils.js +35 -11
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +81 -9
- package/templates/.claude/commands/go.md +9 -0
- package/templates/.claude/commands/harvest.md +39 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +171 -37
- package/templates/.claude/commands/start.md +29 -0
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +434 -24
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- 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/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +13 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +123 -52
- 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/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
- 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 +72 -46
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +773 -2
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +43 -6
- 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/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +574 -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/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -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 +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
|
|
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
|
|
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,114 @@ 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 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
|
-
+ ' —
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
for (const
|
|
253
|
-
process.exit(
|
|
407
|
+
printViolations(bad);
|
|
408
|
+
if (unknown.length) {
|
|
409
|
+
for (const reason of unknown) warn('⚠️ проверка НЕ выполнена: ' + reason);
|
|
410
|
+
process.exit(2);
|
|
254
411
|
}
|
|
255
|
-
|
|
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
|
}
|