diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d39497 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + name: "Tests (PHP ${{ matrix.php }})" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3'] + dependencies: ['highest'] + include: + - php: '8.1' + dependencies: 'lowest' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, json, redis + coverage: xdebug + tools: composer:v2 + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependencies }}- + ${{ runner.os }}-composer-${{ matrix.php }}- + ${{ runner.os }}-composer- + + - name: Install dependencies (highest) + if: matrix.dependencies == 'highest' + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Install dependencies (lowest) + if: matrix.dependencies == 'lowest' + run: composer update --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress + + - name: Run tests + run: vendor/bin/phpunit --testdox + + coverage: + name: "Code Coverage" + runs-on: ubuntu-latest + needs: tests + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, json, redis + coverage: xdebug + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run tests with coverage + run: vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + phpstan: + name: "PHPStan" + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, json, redis + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan analyse + + cs-fixer: + name: "Code Style (informational)" + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, json + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run PHP CS Fixer (dry-run) + run: vendor/bin/php-cs-fixer fix --dry-run --diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c7fd617 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + name: "Create Release" + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + # Get previous tag + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo '') + if [ -z "$PREVIOUS_TAG" ]; then + CHANGELOG=$(git log --oneline --pretty=format:'- %s (%h)' HEAD) + else + CHANGELOG=$(git log --oneline --pretty=format:'- %s (%h)' ${PREVIOUS_TAG}..HEAD) + fi + echo "changelog<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + body: | + ## Changes + + ${{ steps.changelog.outputs.changelog }} + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 57872d0..bf388ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,19 @@ /vendor/ +composer.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Build artifacts +coverage.xml +coverage-html/ +.phpunit.cache/ +.phpstan-cache/ +.php-cs-fixer.cache + +# OS files +.DS_Store +Thumbs.db diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..6eddbf2 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,39 @@ +in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php') + ->notName('*.blade.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(false) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'no_unused_imports' => true, + 'single_quote' => true, + 'blank_line_before_statement' => [ + 'statements' => ['return', 'throw', 'try'], + ], + 'class_attributes_separation' => [ + 'elements' => ['method' => 'one'], + ], + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_order' => true, + 'phpdoc_trim' => true, + 'no_blank_lines_after_phpdoc' => true, + 'return_type_declaration' => ['space_before' => 'none'], + ]) + ->setFinder($finder) + ->setCacheFile('.php-cs-fixer.cache'); diff --git a/composer.json b/composer.json index e232fa9..89e09f9 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,12 @@ "require": { "php": "^8.1", "guzzlehttp/guzzle": "^7.5", + "guzzlehttp/psr7": "^2.4", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/simple-cache": "^3.0", "psr/log": "^3.0", - "react/promise": "^2.9" + "react/promise": "^2.9 || ^3.0" }, "require-dev": { "phpunit/phpunit": "^10.0", @@ -49,8 +50,9 @@ "test": "phpunit", "phpstan": "phpstan analyse", "cs-fix": "php-cs-fixer fix", + "cs-check": "php-cs-fixer fix --dry-run --diff", "check": [ - "@cs-fix", + "@cs-check", "@phpstan", "@test" ] diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..e20c4cb --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,24 @@ +parameters: + level: 6 + paths: + - src + tmpDir: .phpstan-cache + reportUnmatchedIgnoredErrors: false + ignoreErrors: + # Missing iterable value type (too noisy at level 6) + - + identifier: missingType.iterableValue + # Redis extension return types include Redis|int|false union + - + message: '#Comparison operation .+ between \(int\|Redis\|false\) and 0#' + paths: + - src/Cache/RedisCacheAdapter.php + - + message: '#Comparison operation .+ between \(bool\|int\|Redis\) and 0#' + paths: + - src/Cache/RedisCacheAdapter.php + # React\Promise\PromiseInterface generic varies between v2 (not generic) and v3 (generic) + - + message: '#generic interface React\\Promise\\PromiseInterface#' + paths: + - src/UltimateLinkChecker.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..393f3e8 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + tests + + + + + + src + + + + + + + + + + diff --git a/src/Cache/RedisCacheAdapter.php b/src/Cache/RedisCacheAdapter.php index 3832ab4..3aaf3e6 100644 --- a/src/Cache/RedisCacheAdapter.php +++ b/src/Cache/RedisCacheAdapter.php @@ -5,6 +5,7 @@ namespace Qdenka\UltimateLinkChecker\Cache; use DateInterval; +use DateTimeImmutable; use Psr\SimpleCache\CacheInterface; use Redis; @@ -44,14 +45,18 @@ public function set(string $key, mixed $value, DateInterval|int|null $ttl = null $serialized = serialize($value); if ($ttl === null) { - return $this->redis->set($prefixedKey, $serialized); + $result = $this->redis->set($prefixedKey, $serialized); + + return (bool) $result; } if ($ttl instanceof DateInterval) { $ttl = $this->dateIntervalToSeconds($ttl); } - return $this->redis->setex($prefixedKey, $ttl, $serialized); + $result = $this->redis->setex($prefixedKey, $ttl, $serialized); + + return (bool) $result; } /** @@ -60,7 +65,9 @@ public function set(string $key, mixed $value, DateInterval|int|null $ttl = null */ public function delete(string $key): bool { - return $this->redis->del($this->getPrefixedKey($key)) > 0; + $result = $this->redis->del($this->getPrefixedKey($key)); + + return ((int) $result) > 0; } /** @@ -74,7 +81,9 @@ public function clear(): bool return true; } - return $this->redis->del($keys) > 0; + $result = $this->redis->del($keys); + + return ((int) $result) > 0; } /** @@ -125,7 +134,9 @@ public function deleteMultiple(iterable $keys): bool return true; } - return $this->redis->del($prefixedKeys) > 0; + $result = $this->redis->del($prefixedKeys); + + return ((int) $result) > 0; } /** @@ -134,7 +145,9 @@ public function deleteMultiple(iterable $keys): bool */ public function has(string $key): bool { - return $this->redis->exists($this->getPrefixedKey($key)) > 0; + $result = $this->redis->exists($this->getPrefixedKey($key)); + + return ((int) $result) > 0; } /** @@ -152,7 +165,7 @@ private function getPrefixedKey(string $key): string */ private function dateIntervalToSeconds(DateInterval $interval): int { - $reference = new \DateTimeImmutable(); + $reference = new DateTimeImmutable(); $endTime = $reference->add($interval); return $endTime->getTimestamp() - $reference->getTimestamp(); diff --git a/src/Contract/AbstractProvider.php b/src/Contract/AbstractProvider.php index 0ab632a..adb73cc 100644 --- a/src/Contract/AbstractProvider.php +++ b/src/Contract/AbstractProvider.php @@ -4,6 +4,7 @@ namespace Qdenka\UltimateLinkChecker\Contract; +use GuzzleHttp\Psr7\HttpFactory; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; @@ -11,14 +12,27 @@ abstract class AbstractProvider implements ProviderInterface { + protected readonly string $apiKey; + protected readonly ClientInterface $httpClient; + protected readonly RequestFactoryInterface $requestFactory; + protected readonly StreamFactoryInterface $streamFactory; + protected readonly float $timeout; + protected readonly int $retries; + public function __construct( - protected readonly string $apiKey, - protected readonly ?ClientInterface $httpClient = null, - protected readonly ?RequestFactoryInterface $requestFactory = null, - protected readonly ?StreamFactoryInterface $streamFactory = null, - protected readonly float $timeout = 5.0, - protected readonly int $retries = 1 + string $apiKey, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + float $timeout = 5.0, + int $retries = 1 ) { + $this->apiKey = $apiKey; + $this->httpClient = $httpClient ?? new \GuzzleHttp\Client(['timeout' => $timeout]); + $this->requestFactory = $requestFactory ?? new HttpFactory(); + $this->streamFactory = $streamFactory ?? new HttpFactory(); + $this->timeout = $timeout; + $this->retries = $retries; } /** @@ -28,7 +42,6 @@ public function __construct( public function checkBatch(array $urls): array { $results = []; - foreach ($urls as $url) { $results[$url] = $this->check($url); } @@ -36,34 +49,12 @@ public function checkBatch(array $urls): array return $results; } - /** - * @param string $url - * @return string - */ - protected function normalizeUrl(string $url): string - { - if (!preg_match('~^(?:f|ht)tps?://~i', $url)) { - $url = 'http://' . $url; - } - - return trim($url); - } - - /** - * @param string $url - * @return CheckResult - */ - protected function createResult(string $url): CheckResult - { - return new CheckResult($url); - } - /** * Execute an HTTP request with retry logic. * * @param callable $requestCallable A callable that performs the HTTP request and returns a result. - * @return mixed The result of the callable. * @throws \Throwable Re-throws the last exception if all retries fail. + * @return mixed The result of the callable. */ protected function executeWithRetry(callable $requestCallable): mixed { @@ -74,12 +65,31 @@ protected function executeWithRetry(callable $requestCallable): mixed return $requestCallable(); } catch (\Throwable $e) { $lastException = $e; + if ($attempt < $this->retries) { - usleep(100_000 * ($attempt + 1)); // Incremental backoff + usleep(100000 * ($attempt + 1)); // incremental backoff: 100ms, 200ms, 300ms... } } } throw $lastException; } + + /** + * @param string $url + * @return string + */ + protected function normalizeUrl(string $url): string + { + return trim($url); + } + + /** + * @param string $url + * @return CheckResult + */ + protected function createResult(string $url): CheckResult + { + return new CheckResult($url); + } } diff --git a/src/Factory/ProviderFactory.php b/src/Factory/ProviderFactory.php index c9229a6..8a2fc50 100644 --- a/src/Factory/ProviderFactory.php +++ b/src/Factory/ProviderFactory.php @@ -22,8 +22,8 @@ final class ProviderFactory * @param string $apiKey * @param float $timeout * @param int $retries - * @return ProviderInterface * @throws InvalidArgumentException + * @return ProviderInterface */ public static function createProvider( string $name, @@ -40,26 +40,7 @@ public static function createProvider( 'facebook' => new FacebookProvider($apiKey, timeout: $timeout, retries: $retries), 'opswat' => new OPSWATProvider($apiKey, timeout: $timeout, retries: $retries), 'cisco_talos' => new CiscoTalosProvider($apiKey, timeout: $timeout, retries: $retries), - default => throw new InvalidArgumentException(sprintf('Unknown provider "%s"', $name)), + default => throw new InvalidArgumentException(sprintf('Unknown provider: %s', $name)), }; } - - /** - * Get a list of available provider names - * - * @return array - */ - public static function getAvailableProviders(): array - { - return [ - 'google_safebrowsing', - 'yandex_safebrowsing', - 'virustotal', - 'phishtank', - 'ipqualityscore', - 'facebook', - 'opswat', - 'cisco_talos', - ]; - } } diff --git a/src/Provider/CiscoTalosProvider.php b/src/Provider/CiscoTalosProvider.php index e68decd..92ae546 100644 --- a/src/Provider/CiscoTalosProvider.php +++ b/src/Provider/CiscoTalosProvider.php @@ -16,14 +16,14 @@ use Qdenka\UltimateLinkChecker\Result\Threat; /** - * Cisco Talos Intelligence provider. + * Cisco Talos Intelligence URL reputation provider. * - * Uses the Cisco Talos reputation lookup API to check URL/domain reputation. + * Uses the Cisco Talos API to check domain/URL reputation. * Requires a valid Cisco Talos API key. */ final class CiscoTalosProvider extends AbstractProvider { - private const API_URL = 'https://talosintelligence.com/api/v2/url/reputation'; + private const API_URL = 'https://cloud-intel.api.cisco.com/v1/url/reputation'; public function __construct( string $apiKey, @@ -53,8 +53,8 @@ public function getName(): string /** * @param string $url - * @return CheckResult * @throws ProviderException + * @return CheckResult */ public function check(string $url): CheckResult { @@ -63,13 +63,12 @@ public function check(string $url): CheckResult try { return $this->executeWithRetry(function () use ($normalizedUrl, $result): CheckResult { - $domain = $this->extractDomain($normalizedUrl); - + $domain = parse_url($normalizedUrl, PHP_URL_HOST) ?: $normalizedUrl; $payload = json_encode(['url' => $domain], JSON_THROW_ON_ERROR); $request = $this->requestFactory->createRequest('POST', self::API_URL) - ->withHeader('Content-Type', 'application/json') - ->withHeader('Authorization', 'Bearer ' . $this->apiKey); + ->withHeader('Authorization', 'Bearer ' . $this->apiKey) + ->withHeader('Content-Type', 'application/json'); $request = $request->withBody( $this->streamFactory->createStream($payload) @@ -78,7 +77,55 @@ public function check(string $url): CheckResult $response = $this->httpClient->sendRequest($request); $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - $this->processResults($data, $normalizedUrl, $result); + $reputation = $data['reputation'] ?? $data['web_reputation'] ?? null; + + $isMalicious = false; + $threatCategories = []; + + if (is_array($reputation)) { + $score = $reputation['score'] ?? $reputation['threat_score'] ?? null; + // Talos scores: negative = bad reputation + if ($score !== null && $score < -5) { + $isMalicious = true; + } + + $categories = $reputation['categories'] ?? $data['categories'] ?? []; + $dangerousCategories = [ + 'malware', 'phishing', 'botnet', 'spam', + 'suspicious', 'untrusted', 'compromised', + ]; + + foreach ($categories as $category) { + $categoryName = is_array($category) ? ($category['name'] ?? '') : (string) $category; + if (in_array(strtolower($categoryName), $dangerousCategories, true)) { + $isMalicious = true; + $threatCategories[] = $categoryName; + } + } + } elseif (is_numeric($reputation)) { + if ((float) $reputation < -5) { + $isMalicious = true; + } + } + + if ($isMalicious) { + $threat = new Threat( + type: !empty($threatCategories) ? strtoupper($threatCategories[0]) : 'MALICIOUS_REPUTATION', + platform: 'ANY_PLATFORM', + description: sprintf( + 'This URL/domain has a poor reputation score on Cisco Talos%s', + !empty($threatCategories) ? ': ' . implode(', ', $threatCategories) : '' + ), + url: $normalizedUrl, + metadata: [ + 'domain' => $domain, + 'reputation' => $reputation, + 'categories' => $threatCategories, + ] + ); + + $result->addThreat($this->getName(), $threat); + } return $result; }); @@ -90,106 +137,4 @@ public function check(string $url): CheckResult ); } } - - /** - * Process Cisco Talos API results and add threats if found. - * - * @param array $data - * @param string $url - * @param CheckResult $result - */ - private function processResults(array $data, string $url, CheckResult $result): void - { - $reputation = $data['reputation'] ?? null; - $categories = $data['categories'] ?? []; - - // Cisco Talos reputation: "poor" or "very_poor" means dangerous - $dangerousReputations = ['poor', 'very_poor', 'untrusted']; - $dangerousCategories = [ - 'malware', 'phishing', 'spam', 'botnets', - 'exploit_kit', 'ransomware', 'cryptomining' - ]; - - $isDangerous = in_array(strtolower((string) $reputation), $dangerousReputations, true); - - $matchedCategories = []; - foreach ($categories as $category) { - $categoryName = strtolower(is_array($category) ? ($category['name'] ?? '') : (string) $category); - if (in_array($categoryName, $dangerousCategories, true)) { - $matchedCategories[] = $categoryName; - $isDangerous = true; - } - } - - if ($isDangerous) { - $threatType = $this->determineThreatType($matchedCategories, (string) $reputation); - - $threat = new Threat( - type: $threatType, - platform: 'ANY_PLATFORM', - description: sprintf( - 'Cisco Talos rates this URL with reputation "%s"%s', - $reputation ?? 'unknown', - !empty($matchedCategories) ? ' (categories: ' . implode(', ', $matchedCategories) . ')' : '' - ), - url: $url, - metadata: [ - 'reputation' => $reputation, - 'categories' => $categories, - 'matched_categories' => $matchedCategories, - ] - ); - - $result->addThreat($this->getName(), $threat); - } - } - - /** - * Determine the primary threat type from matched categories. - * - * @param array $categories - * @param string $reputation - * @return string - */ - private function determineThreatType(array $categories, string $reputation): string - { - if (in_array('malware', $categories, true) || in_array('ransomware', $categories, true)) { - return 'MALWARE'; - } - - if (in_array('phishing', $categories, true)) { - return 'PHISHING'; - } - - if (in_array('spam', $categories, true)) { - return 'SPAM'; - } - - if (in_array('botnets', $categories, true)) { - return 'BOTNET'; - } - - if (in_array('exploit_kit', $categories, true)) { - return 'EXPLOIT_KIT'; - } - - if (in_array('cryptomining', $categories, true)) { - return 'CRYPTOMINING'; - } - - return 'UNTRUSTED'; - } - - /** - * Extract domain from URL. - * - * @param string $url - * @return string - */ - private function extractDomain(string $url): string - { - $parsed = parse_url($url); - - return $parsed['host'] ?? $url; - } } diff --git a/src/Provider/FacebookProvider.php b/src/Provider/FacebookProvider.php index a7ce556..50f8a94 100644 --- a/src/Provider/FacebookProvider.php +++ b/src/Provider/FacebookProvider.php @@ -18,12 +18,12 @@ /** * Facebook URL Security provider. * - * Uses the Facebook Graph API to check URL sharing safety. - * Requires a valid Facebook App access token (app_id|app_secret). + * Uses the Facebook Graph API v18.0 to check if a URL is safe for sharing. + * Requires a valid Facebook App access token. */ final class FacebookProvider extends AbstractProvider { - private const API_URL = 'https://graph.facebook.com/v18.0/'; + private const API_URL = 'https://graph.facebook.com/v18.0'; public function __construct( string $apiKey, @@ -53,8 +53,8 @@ public function getName(): string /** * @param string $url - * @return CheckResult * @throws ProviderException + * @return CheckResult */ public function check(string $url): CheckResult { @@ -63,49 +63,52 @@ public function check(string $url): CheckResult try { return $this->executeWithRetry(function () use ($normalizedUrl, $result): CheckResult { - $queryParams = http_build_query([ - 'access_token' => $this->apiKey, - 'scrape' => 'true', - 'id' => $normalizedUrl, - ]); + $encodedUrl = urlencode($normalizedUrl); + $apiUrl = sprintf( + '%s/?id=%s&scrape=true&access_token=%s', + self::API_URL, + $encodedUrl, + $this->apiKey + ); - $request = $this->requestFactory->createRequest('POST', self::API_URL . '?' . $queryParams) - ->withHeader('Content-Type', 'application/x-www-form-urlencoded'); + $request = $this->requestFactory->createRequest('POST', $apiUrl); $response = $this->httpClient->sendRequest($request); + $statusCode = $response->getStatusCode(); $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - // Facebook flags unsafe URLs in the 'og_object' or via error responses - if (isset($data['error'])) { - $errorMessage = $data['error']['message'] ?? 'Unknown error'; + // Facebook returns error for blocked/restricted URLs + if ($statusCode >= 400 || isset($data['error'])) { + $errorMessage = $data['error']['message'] ?? 'URL blocked by Facebook'; + $errorCode = $data['error']['code'] ?? 0; - // Error code 1 with specific messages indicates blocked/unsafe URL - if ($this->isSecurityError($data['error'])) { + // Error code 100 with specific subcode indicates URL restriction + $isBlocked = ($errorCode === 100) || + str_contains(strtolower($errorMessage), 'blocked') || + str_contains(strtolower($errorMessage), 'restricted') || + str_contains(strtolower($errorMessage), 'spam') || + str_contains(strtolower($errorMessage), 'unsafe'); + + if ($isBlocked) { $threat = new Threat( type: 'BLOCKED_URL', platform: 'FACEBOOK', - description: sprintf('This URL is blocked by Facebook: %s', $errorMessage), + description: sprintf( + 'This URL is blocked or restricted on Facebook: %s', + $errorMessage + ), url: $normalizedUrl, - metadata: $data['error'] + metadata: [ + 'error_code' => $errorCode, + 'error_subcode' => $data['error']['error_subcode'] ?? null, + 'error_message' => $errorMessage, + ] ); $result->addThreat($this->getName(), $threat); } } - // Check if the share is restricted - if (isset($data['share']) && isset($data['share']['error'])) { - $threat = new Threat( - type: 'RESTRICTED_URL', - platform: 'FACEBOOK', - description: 'This URL has sharing restrictions on Facebook', - url: $normalizedUrl, - metadata: $data['share'] - ); - - $result->addThreat($this->getName(), $threat); - } - return $result; }); } catch (GuzzleException $e) { @@ -116,28 +119,4 @@ public function check(string $url): CheckResult ); } } - - /** - * Determine if a Facebook API error indicates a security issue. - * - * @param array $error - * @return bool - */ - private function isSecurityError(array $error): bool - { - $securityKeywords = ['spam', 'abuse', 'malicious', 'unsafe', 'blocked', 'restricted']; - $message = strtolower($error['message'] ?? ''); - - foreach ($securityKeywords as $keyword) { - if (str_contains($message, $keyword)) { - return true; - } - } - - // Facebook error code 368 = temporarily blocked for policies - // Facebook error code 1609005 = link blocked - $blockedCodes = [368, 1609005]; - - return in_array($error['code'] ?? 0, $blockedCodes, true); - } } diff --git a/src/Provider/GoogleSafeBrowsingProvider.php b/src/Provider/GoogleSafeBrowsingProvider.php index 1f94d13..4998a67 100644 --- a/src/Provider/GoogleSafeBrowsingProvider.php +++ b/src/Provider/GoogleSafeBrowsingProvider.php @@ -47,8 +47,8 @@ public function getName(): string /** * @param string $url - * @return CheckResult * @throws ProviderException + * @return CheckResult */ public function check(string $url): CheckResult { @@ -57,24 +57,47 @@ public function check(string $url): CheckResult try { return $this->executeWithRetry(function () use ($normalizedUrl, $result): CheckResult { - $payload = $this->buildRequestPayload([$normalizedUrl]); - $request = $this->requestFactory->createRequest('POST', $this->getApiUrl()) + $payload = json_encode([ + 'client' => [ + 'clientId' => 'ultimatelinkchecker', + 'clientVersion' => '1.0.0' + ], + 'threatInfo' => [ + 'threatTypes' => [ + 'MALWARE', + 'SOCIAL_ENGINEERING', + 'UNWANTED_SOFTWARE', + 'POTENTIALLY_HARMFUL_APPLICATION', + 'THREAT_TYPE_UNSPECIFIED' + ], + 'platformTypes' => ['ANY_PLATFORM'], + 'threatEntryTypes' => ['URL'], + 'threatEntries' => [ + ['url' => $normalizedUrl] + ] + ] + ], JSON_THROW_ON_ERROR); + + $request = $this->requestFactory->createRequest('POST', self::API_URL . '?key=' . $this->apiKey) ->withHeader('Content-Type', 'application/json'); $request = $request->withBody( - $this->streamFactory->createStream(json_encode($payload, JSON_THROW_ON_ERROR)) + $this->streamFactory->createStream($payload) ); $response = $this->httpClient->sendRequest($request); $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - if (isset($data['matches']) && is_array($data['matches'])) { + if (!empty($data['matches'])) { foreach ($data['matches'] as $match) { $threat = new Threat( type: $match['threatType'] ?? 'UNKNOWN', platform: $match['platformType'] ?? 'ANY_PLATFORM', - description: $this->getThreatDescription($match['threatType'] ?? 'UNKNOWN'), - url: $match['threat']['url'] ?? $normalizedUrl, + description: sprintf( + 'This URL has been flagged by Google Safe Browsing as %s', + $match['threatType'] ?? 'UNKNOWN' + ), + url: $normalizedUrl, metadata: $match ); @@ -92,58 +115,4 @@ public function check(string $url): CheckResult ); } } - - /** - * @param array $urls - * @return array - */ - private function buildRequestPayload(array $urls): array - { - $threatInfo = [ - 'threatTypes' => [ - 'MALWARE', - 'SOCIAL_ENGINEERING', - 'UNWANTED_SOFTWARE', - 'POTENTIALLY_HARMFUL_APPLICATION' - ], - 'platformTypes' => ['ANY_PLATFORM'], - 'threatEntryTypes' => ['URL'], - 'threatEntries' => [] - ]; - - foreach ($urls as $url) { - $threatInfo['threatEntries'][] = ['url' => $url]; - } - - return [ - 'client' => [ - 'clientId' => 'ultimatelinkchecker', - 'clientVersion' => '1.0.0' - ], - 'threatInfo' => $threatInfo - ]; - } - - /** - * @return string - */ - private function getApiUrl(): string - { - return self::API_URL . '?key=' . $this->apiKey; - } - - /** - * @param string $threatType - * @return string - */ - private function getThreatDescription(string $threatType): string - { - return match ($threatType) { - 'MALWARE' => 'This URL contains malware', - 'SOCIAL_ENGINEERING' => 'This URL contains phishing or social engineering content', - 'UNWANTED_SOFTWARE' => 'This URL contains unwanted software', - 'POTENTIALLY_HARMFUL_APPLICATION' => 'This URL contains a potentially harmful application', - default => 'This URL has been identified as unsafe' - }; - } } diff --git a/src/Provider/IPQualityScoreProvider.php b/src/Provider/IPQualityScoreProvider.php index 9205fa2..fbecdbf 100644 --- a/src/Provider/IPQualityScoreProvider.php +++ b/src/Provider/IPQualityScoreProvider.php @@ -17,7 +17,7 @@ final class IPQualityScoreProvider extends AbstractProvider { - private const API_URL = 'https://ipqualityscore.com/api/json/url/%s/%s'; + private const API_URL = 'https://www.ipqualityscore.com/api/json/url'; public function __construct( string $apiKey, @@ -47,8 +47,8 @@ public function getName(): string /** * @param string $url - * @return CheckResult * @throws ProviderException + * @return CheckResult */ public function check(string $url): CheckResult { @@ -57,33 +57,50 @@ public function check(string $url): CheckResult try { return $this->executeWithRetry(function () use ($normalizedUrl, $result): CheckResult { - $apiUrl = sprintf(self::API_URL, $this->apiKey, urlencode($normalizedUrl)); - $request = $this->requestFactory->createRequest('GET', $apiUrl); - $response = $this->httpClient->sendRequest($request); + $request = $this->requestFactory->createRequest( + 'GET', + self::API_URL . '/' . $this->apiKey . '/' . urlencode($normalizedUrl) + ); + $response = $this->httpClient->sendRequest($request); $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - if (!isset($data['success']) || $data['success'] !== true) { - throw new ProviderException( - sprintf('IPQualityScore API error: %s', $data['message'] ?? 'Unknown error') - ); - } + if (isset($data['unsafe']) && $data['unsafe'] === true) { + $threatTypes = []; + + if (!empty($data['phishing'])) { + $threatTypes[] = 'PHISHING'; + } + + if (!empty($data['malware'])) { + $threatTypes[] = 'MALWARE'; + } - $isSuspicious = $data['suspicious'] ?? false; - $isPhishing = $data['phishing'] ?? false; - $isMalware = $data['malware'] ?? false; - $isSpamming = $data['spamming'] ?? false; - $isUnsafe = $data['unsafe'] ?? false; + if (!empty($data['suspicious'])) { + $threatTypes[] = 'SUSPICIOUS'; + } - if ($isSuspicious || $isPhishing || $isMalware || $isSpamming || $isUnsafe) { - $threatType = $this->determineThreatType($data); + if (!empty($data['spamming'])) { + $threatTypes[] = 'SPAM'; + } $threat = new Threat( - type: $threatType, + type: !empty($threatTypes) ? $threatTypes[0] : 'UNSAFE', platform: 'ANY_PLATFORM', - description: $this->getThreatDescription($threatType), + description: sprintf( + 'This URL has been flagged as unsafe by IPQualityScore. Risk score: %d/100. Categories: %s', + $data['risk_score'] ?? 0, + implode(', ', $threatTypes) ?: 'UNSAFE' + ), url: $normalizedUrl, - metadata: $data + metadata: [ + 'risk_score' => $data['risk_score'] ?? null, + 'domain' => $data['domain'] ?? null, + 'ip_address' => $data['ip_address'] ?? null, + 'categories' => $threatTypes, + 'adult' => $data['adult'] ?? false, + 'parking' => $data['parking'] ?? false, + ] ); $result->addThreat($this->getName(), $threat); @@ -99,54 +116,4 @@ public function check(string $url): CheckResult ); } } - - /** - * Determine the most severe threat type from the response - * - * @param array $data - * @return string - */ - private function determineThreatType(array $data): string - { - if ($data['malware'] ?? false) { - return 'MALWARE'; - } - - if ($data['phishing'] ?? false) { - return 'PHISHING'; - } - - if ($data['parking'] ?? false) { - return 'PARKING_DOMAIN'; - } - - if ($data['spamming'] ?? false) { - return 'SPAM'; - } - - if ($data['suspicious'] ?? false) { - return 'SUSPICIOUS'; - } - - return 'UNSAFE'; - } - - /** - * Get a human-readable description of the threat - * - * @param string $threatType - * @return string - */ - private function getThreatDescription(string $threatType): string - { - return match ($threatType) { - 'MALWARE' => 'This URL contains or distributes malware', - 'PHISHING' => 'This URL is a phishing site designed to steal sensitive information', - 'PARKING_DOMAIN' => 'This domain is parked and may contain misleading ads', - 'SPAM' => 'This URL is associated with spam or unwanted communications', - 'SUSPICIOUS' => 'This URL exhibits suspicious characteristics', - 'UNSAFE' => 'This URL was identified as unsafe', - default => 'This URL was flagged as potentially harmful' - }; - } } diff --git a/src/Provider/OPSWATProvider.php b/src/Provider/OPSWATProvider.php index dcb6997..8557e7e 100644 --- a/src/Provider/OPSWATProvider.php +++ b/src/Provider/OPSWATProvider.php @@ -53,8 +53,8 @@ public function getName(): string /** * @param string $url - * @return CheckResult * @throws ProviderException + * @return CheckResult */ public function check(string $url): CheckResult { @@ -118,7 +118,7 @@ private function processResults(array $data, string $url, CheckResult $result): if (!empty($maliciousSources)) { $threatType = $this->determineThreatType($maliciousSources); - $providerNames = array_map(fn(array $s) => $s['provider'], $maliciousSources); + $providerNames = array_map(fn (array $s) => $s['provider'], $maliciousSources); $threat = new Threat( type: $threatType, diff --git a/src/Provider/PhishTankProvider.php b/src/Provider/PhishTankProvider.php index 4a598b0..2093c6e 100644 --- a/src/Provider/PhishTankProvider.php +++ b/src/Provider/PhishTankProvider.php @@ -47,8 +47,8 @@ public function getName(): string /** * @param string $url - * @return CheckResult * @throws ProviderException + * @return CheckResult */ public function check(string $url): CheckResult { @@ -57,36 +57,36 @@ public function check(string $url): CheckResult try { return $this->executeWithRetry(function () use ($normalizedUrl, $result): CheckResult { - $formData = [ + $body = http_build_query([ 'url' => $normalizedUrl, - 'api_key' => $this->apiKey, - 'format' => 'json' - ]; + 'format' => 'json', + 'app_key' => $this->apiKey + ]); $request = $this->requestFactory->createRequest('POST', self::API_URL) - ->withHeader('Content-Type', 'application/x-www-form-urlencoded') - ->withHeader('User-Agent', 'UltimateLinkChecker/1.0'); + ->withHeader('Content-Type', 'application/x-www-form-urlencoded'); $request = $request->withBody( - $this->streamFactory->createStream(http_build_query($formData)) + $this->streamFactory->createStream($body) ); $response = $this->httpClient->sendRequest($request); $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - if (isset($data['results']['in_database']) && $data['results']['in_database'] === true - && !empty($data['results']['phish']) - ) { + if (isset($data['results']['in_database']) && $data['results']['in_database'] === true) { $threat = new Threat( type: 'PHISHING', platform: 'ANY_PLATFORM', - description: 'This URL was identified as a phishing site by PhishTank', + description: sprintf( + 'This URL has been identified as a phishing site by PhishTank. Verified: %s', + isset($data['results']['verified']) && $data['results']['verified'] ? 'Yes' : 'No' + ), url: $normalizedUrl, metadata: [ 'phish_id' => $data['results']['phish_id'] ?? null, 'verified' => $data['results']['verified'] ?? false, 'verified_at' => $data['results']['verified_at'] ?? null, - 'phish_detail_url' => $data['results']['phish_detail_page'] ?? null + 'valid' => $data['results']['valid'] ?? false ] ); diff --git a/src/Provider/VirusTotalProvider.php b/src/Provider/VirusTotalProvider.php index bf65b23..7266cf5 100644 --- a/src/Provider/VirusTotalProvider.php +++ b/src/Provider/VirusTotalProvider.php @@ -48,8 +48,8 @@ public function getName(): string /** * @param string $url - * @return CheckResult * @throws ProviderException + * @return CheckResult */ public function check(string $url): CheckResult { @@ -92,10 +92,10 @@ public function check(string $url): CheckResult /** * @param string $url - * @return string * @throws ClientExceptionInterface * @throws ProviderException * @throws \JsonException + * @return string */ private function submitUrl(string $url): string { @@ -119,9 +119,9 @@ private function submitUrl(string $url): string /** * @param string $urlId - * @return array * @throws \JsonException * @throws ClientExceptionInterface + * @return array */ private function getAnalysisResults(string $urlId): array { diff --git a/src/Provider/YandexSafeBrowsingProvider.php b/src/Provider/YandexSafeBrowsingProvider.php index c69aea6..b330fcc 100644 --- a/src/Provider/YandexSafeBrowsingProvider.php +++ b/src/Provider/YandexSafeBrowsingProvider.php @@ -47,8 +47,8 @@ public function getName(): string /** * @param string $url - * @return CheckResult * @throws ProviderException + * @return CheckResult */ public function check(string $url): CheckResult { @@ -57,25 +57,45 @@ public function check(string $url): CheckResult try { return $this->executeWithRetry(function () use ($normalizedUrl, $result): CheckResult { - $payload = $this->buildRequestPayload([$normalizedUrl]); - $request = $this->requestFactory->createRequest('POST', self::API_URL) - ->withHeader('Content-Type', 'application/json') - ->withHeader('Authorization', 'ApiKey ' . $this->apiKey); + $payload = json_encode([ + 'client' => [ + 'clientId' => 'ultimatelinkchecker', + 'clientVersion' => '1.0.0' + ], + 'threatInfo' => [ + 'threatTypes' => [ + 'MALWARE', + 'SOCIAL_ENGINEERING', + 'UNWANTED_SOFTWARE', + ], + 'platformTypes' => ['ANY_PLATFORM'], + 'threatEntryTypes' => ['URL'], + 'threatEntries' => [ + ['url' => $normalizedUrl] + ] + ] + ], JSON_THROW_ON_ERROR); + + $request = $this->requestFactory->createRequest('POST', self::API_URL . '?key=' . $this->apiKey) + ->withHeader('Content-Type', 'application/json'); $request = $request->withBody( - $this->streamFactory->createStream(json_encode($payload, JSON_THROW_ON_ERROR)) + $this->streamFactory->createStream($payload) ); $response = $this->httpClient->sendRequest($request); $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - if (isset($data['matches']) && is_array($data['matches'])) { + if (!empty($data['matches'])) { foreach ($data['matches'] as $match) { $threat = new Threat( type: $match['threatType'] ?? 'UNKNOWN', platform: $match['platformType'] ?? 'ANY_PLATFORM', - description: $this->getThreatDescription($match['threatType'] ?? 'UNKNOWN'), - url: $match['threat']['url'] ?? $normalizedUrl, + description: sprintf( + 'This URL has been flagged by Yandex Safe Browsing as %s', + $match['threatType'] ?? 'UNKNOWN' + ), + url: $normalizedUrl, metadata: $match ); @@ -93,49 +113,4 @@ public function check(string $url): CheckResult ); } } - - /** - * @param array $urls - * @return array - */ - private function buildRequestPayload(array $urls): array - { - $threatEntries = []; - foreach ($urls as $url) { - $threatEntries[] = ['url' => $url]; - } - - return [ - 'client' => [ - 'clientId' => 'ultimatelinkchecker', - 'clientVersion' => '1.0.0' - ], - 'threatInfo' => [ - 'threatTypes' => [ - 'MALWARE', - 'SOCIAL_ENGINEERING', - 'UNWANTED_SOFTWARE', - 'HARMFUL_DOWNLOAD' - ], - 'platformTypes' => ['ANY_PLATFORM'], - 'threatEntryTypes' => ['URL'], - 'threatEntries' => $threatEntries - ] - ]; - } - - /** - * @param string $threatType - * @return string - */ - private function getThreatDescription(string $threatType): string - { - return match ($threatType) { - 'MALWARE' => 'This URL contains malware according to Yandex', - 'SOCIAL_ENGINEERING' => 'This URL contains phishing or social engineering content according to Yandex', - 'UNWANTED_SOFTWARE' => 'This URL contains unwanted software according to Yandex', - 'HARMFUL_DOWNLOAD' => 'This URL leads to harmful downloads according to Yandex', - default => 'This URL has been identified as unsafe by Yandex' - }; - } } diff --git a/src/UltimateLinkChecker.php b/src/UltimateLinkChecker.php index c9b8824..f6cb6c1 100644 --- a/src/UltimateLinkChecker.php +++ b/src/UltimateLinkChecker.php @@ -40,6 +40,7 @@ public function __construct( public function addProvider(ProviderInterface $provider): self { $this->providers[$provider->getName()] = $provider; + return $this; } @@ -68,8 +69,8 @@ public function getProviders(): array * Get a specific provider by name * * @param string $name - * @return ProviderInterface * @throws ProviderNotFoundException + * @return ProviderInterface */ public function getProvider(string $name): ProviderInterface { @@ -86,9 +87,9 @@ public function getProvider(string $name): ProviderInterface * @param string $url * @param array|null $providerNames * @param string $consensus - * @return AggregateResult * @throws InvalidArgumentException * @throws ProviderNotFoundException + * @return AggregateResult */ public function check( string $url, @@ -116,6 +117,7 @@ public function check( 'url' => $url, 'error' => $e->getMessage(), ]); + throw $e; } } @@ -131,9 +133,9 @@ public function check( * @param array $urls * @param array|null $providerNames * @param string $consensus - * @return array * @throws InvalidArgumentException * @throws ProviderNotFoundException + * @return array */ public function checkBatch( array $urls, @@ -158,7 +160,7 @@ public function checkBatch( * @param string $url * @param array|null $providerNames * @param string $consensus - * @return PromiseInterface + * @return PromiseInterface */ public function checkAsync( string $url, @@ -210,7 +212,7 @@ function (array $resolvedResults) use ($url, $consensus): AggregateResult { * @param array $urls * @param array|null $providerNames * @param string $consensus - * @return array> + * @return array */ public function checkBatchAsync( array $urls, @@ -246,6 +248,7 @@ private function checkWithProvider(ProviderInterface $provider, string $url): Ch 'provider' => $provider->getName(), 'url' => $url, ]); + return $cached; } } @@ -280,8 +283,8 @@ private function generateCacheKey(string $providerName, string $url): string * Resolve providers based on provider names * * @param array|null $providerNames - * @return array * @throws ProviderNotFoundException + * @return array */ private function resolveProviders(?array $providerNames = null): array {