service = new AssetTrackerService; } protected function getPackageProviders($app): array { return []; } /** * Test that valid Composer package names are accepted. */ #[Test] #[DataProvider('validComposerPackageNames')] public function it_accepts_valid_composer_package_names(string $packageName): void { $reflection = new \ReflectionClass($this->service); $method = $reflection->getMethod('validatePackageName'); $method->setAccessible(true); $result = $method->invoke($this->service, $packageName, Asset::TYPE_COMPOSER); $this->assertEquals($packageName, $result); } /** * Test that valid NPM package names are accepted. */ #[Test] #[DataProvider('validNpmPackageNames')] public function it_accepts_valid_npm_package_names(string $packageName): void { $reflection = new \ReflectionClass($this->service); $method = $reflection->getMethod('validatePackageName'); $method->setAccessible(true); $result = $method->invoke($this->service, $packageName, Asset::TYPE_NPM); $this->assertEquals($packageName, $result); } /** * Test that shell injection attempts in Composer package names are rejected. */ #[Test] #[DataProvider('shellInjectionAttempts')] public function it_rejects_shell_injection_in_composer_package_names(string $maliciousInput): void { Log::shouldReceive('warning')->once(); $reflection = new \ReflectionClass($this->service); $method = $reflection->getMethod('validatePackageName'); $method->setAccessible(true); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid package name format'); $method->invoke($this->service, $maliciousInput, Asset::TYPE_COMPOSER); } /** * Test that shell injection attempts in NPM package names are rejected. */ #[Test] #[DataProvider('shellInjectionAttempts')] public function it_rejects_shell_injection_in_npm_package_names(string $maliciousInput): void { Log::shouldReceive('warning')->once(); $reflection = new \ReflectionClass($this->service); $method = $reflection->getMethod('validatePackageName'); $method->setAccessible(true); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid package name format'); $method->invoke($this->service, $maliciousInput, Asset::TYPE_NPM); } /** * Test that updateComposerPackage returns error for invalid package name. */ #[Test] public function it_returns_error_for_invalid_composer_package_in_update(): void { Log::shouldReceive('warning')->once(); $asset = new Asset([ 'package_name' => 'vendor/package; rm -rf /', 'type' => Asset::TYPE_COMPOSER, ]); $reflection = new \ReflectionClass($this->service); $method = $reflection->getMethod('updateComposerPackage'); $method->setAccessible(true); $result = $method->invoke($this->service, $asset); $this->assertEquals('error', $result['status']); $this->assertEquals('Invalid package name format', $result['message']); } /** * Test that updateNpmPackage returns error for invalid package name. */ #[Test] public function it_returns_error_for_invalid_npm_package_in_update(): void { Log::shouldReceive('warning')->once(); $asset = new Asset([ 'package_name' => 'package`whoami`', 'type' => Asset::TYPE_NPM, ]); $reflection = new \ReflectionClass($this->service); $method = $reflection->getMethod('updateNpmPackage'); $method->setAccessible(true); $result = $method->invoke($this->service, $asset); $this->assertEquals('error', $result['status']); $this->assertEquals('Invalid package name format', $result['message']); } /** * Test that checkCustomComposerRegistry returns error for invalid package name. */ #[Test] public function it_returns_error_for_invalid_package_in_custom_registry_check(): void { Log::shouldReceive('warning')->once(); $asset = new Asset([ 'package_name' => '$(cat /etc/passwd)', 'type' => Asset::TYPE_COMPOSER, 'registry_url' => 'https://custom.registry.com', ]); $reflection = new \ReflectionClass($this->service); $method = $reflection->getMethod('checkCustomComposerRegistry'); $method->setAccessible(true); $result = $method->invoke($this->service, $asset); $this->assertEquals('error', $result['status']); $this->assertEquals('Invalid package name format', $result['message']); } /** * Test that Process::run is called with array syntax for valid packages. */ #[Test] public function it_uses_array_syntax_for_process_run(): void { Process::fake([ '*' => Process::result(output: '{"versions":["1.0.0"]}'), ]); $asset = new Asset([ 'package_name' => 'vendor/package', 'type' => Asset::TYPE_COMPOSER, 'registry_url' => 'https://custom.registry.com', ]); $reflection = new \ReflectionClass($this->service); $method = $reflection->getMethod('checkCustomComposerRegistry'); $method->setAccessible(true); $method->invoke($this->service, $asset); // Verify array syntax was used (not string interpolation) Process::assertRan(function ($process) { // The command should be an array, not a string with interpolation return is_array($process->command) && $process->command === ['composer', 'show', 'vendor/package', '--format=json']; }); } /** * Data provider for valid Composer package names. */ public static function validComposerPackageNames(): array { return [ 'simple package' => ['vendor/package'], 'with hyphen' => ['my-vendor/my-package'], 'with underscore' => ['my_vendor/my_package'], 'with dots' => ['vendor.name/package.name'], 'with numbers' => ['vendor123/package456'], 'laravel package' => ['laravel/framework'], 'symfony component' => ['symfony/console'], 'complex name' => ['my-vendor123/complex_package.name'], 'livewire flux' => ['livewire/flux-pro'], ]; } /** * Data provider for valid NPM package names. */ public static function validNpmPackageNames(): array { return [ 'simple package' => ['lodash'], 'with hyphen' => ['my-package'], 'with underscore' => ['my_package'], 'with dot' => ['package.js'], 'scoped package' => ['@scope/package'], 'scoped with hyphen' => ['@my-scope/my-package'], 'scoped complex' => ['@angular/core'], 'alpinejs' => ['alpinejs'], 'tailwindcss' => ['tailwindcss'], 'vue' => ['vue'], ]; } /** * Data provider for shell injection attempts. */ public static function shellInjectionAttempts(): array { return [ 'command substitution with backticks' => ['package`whoami`'], 'command substitution with $()' => ['$(cat /etc/passwd)'], 'semicolon injection' => ['vendor/package; rm -rf /'], 'pipe injection' => ['vendor/package | cat /etc/passwd'], 'ampersand injection' => ['vendor/package && rm -rf /'], 'or injection' => ['vendor/package || rm -rf /'], 'newline injection' => ["vendor/package\nrm -rf /"], 'redirect injection' => ['vendor/package > /tmp/pwned'], 'redirect input injection' => ['vendor/package < /etc/passwd'], 'single quote escape' => ["vendor/package'; rm -rf /'"], 'double quote escape' => ['vendor/package"; rm -rf /"'], 'space injection' => ['vendor/package rm -rf /'], 'glob injection' => ['vendor/*'], 'question mark glob' => ['vendor/pack?ge'], 'bracket glob' => ['vendor/pack[a]ge'], 'curly brace expansion' => ['vendor/{a,b}'], 'tilde expansion' => ['~/../../etc/passwd'], 'null byte injection' => ["vendor/package\x00rm"], 'env variable injection' => ['$HOME/package'], 'backtick in scoped npm' => ['@scope`whoami`/package'], ]; } }