diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fa46b93..4650aa7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,5 +19,3 @@ permissions: jobs: php: uses: typisttech/.github/.github/workflows/lint-php.yml@v3 - with: - phpstan: false diff --git a/composer.json b/composer.json index f111ad6..da774eb 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,12 @@ "require": { "php": "^8.3", "composer/semver": "^3.4", - "guzzlehttp/guzzle": "^7.9" + "guzzlehttp/guzzle": "^7.9", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0" }, "require-dev": { "mockery/mockery": "^1.6", @@ -48,7 +53,8 @@ }, "config": { "allow-plugins": { - "pestphp/pest-plugin": true + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true }, "sort-packages": true }, @@ -57,6 +63,11 @@ "curl -o ./tests/Fixtures/vulnerabilities.production.json https://www.wordfence.com/api/intelligence/v2/vulnerabilities/production", "curl -o ./tests/Fixtures/vulnerabilities.scanner.json https://www.wordfence.com/api/intelligence/v2/vulnerabilities/scanner" ], + "lint": [ + "@composer normalize --dry-run", + "pint --test", + "phpstan analyse" + ], "pest": "pest -d memory_limit=512M", "pest:e2e": "pest -d memory_limit=512M --group=e2e", "pest:feature": "pest -d memory_limit=512M --group=feature", diff --git a/composer.lock b/composer.lock index cfeb700..30ae52e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d4ebfc6bc07f79c187b7e1634fea8eca", + "content-hash": "fc6f62ad58af00bd7ee65a5daea6682a", "packages": [ { "name": "composer/semver", @@ -408,6 +408,251 @@ ], "time": "2025-08-23T21:21:41+00:00" }, + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.31", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ead89849d879fe203ce9292c6ef5e7e76f867b96", + "reference": "ead89849d879fe203ce9292c6ef5e7e76f867b96", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-10-10T14:14:11+00:00" + }, + { + "name": "phpstan/phpstan-deprecation-rules", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", + "reference": "468e02c9176891cc901143da118f09dc9505fc2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/468e02c9176891cc901143da118f09dc9505fc2f", + "reference": "468e02c9176891cc901143da118f09dc9505fc2f", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.15" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "support": { + "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.3" + }, + "time": "2025-05-14T10:56:57+00:00" + }, + { + "name": "phpstan/phpstan-mockery", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-mockery.git", + "reference": "89a949d0ac64298e88b7c7fa00caee565c198394" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-mockery/zipball/89a949d0ac64298e88b7c7fa00caee565c198394", + "reference": "89a949d0ac64298e88b7c7fa00caee565c198394", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.0" + }, + "require-dev": { + "mockery/mockery": "^1.6.11", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan Mockery extension", + "support": { + "issues": "https://github.com/phpstan/phpstan-mockery/issues", + "source": "https://github.com/phpstan/phpstan-mockery/tree/2.0.0" + }, + "time": "2024-10-14T03:18:12+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "2.0.7", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/d6211c46213d4181054b3d77b10a5c5cb0d59538", + "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.29" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.7" + }, + "time": "2025-09-26T11:19:08+00:00" + }, { "name": "psr/http-client", "version": "1.0.3", diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..309bd4f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: max + paths: + - src diff --git a/src/AffectedVersionsParser.php b/src/AffectedVersionsParser.php index 1a7fc4d..9f624c1 100644 --- a/src/AffectedVersionsParser.php +++ b/src/AffectedVersionsParser.php @@ -10,18 +10,28 @@ readonly class AffectedVersionsParser { - private const string UNKNOWN = 'unknown'; - public function __construct( private VersionParser $parser = new VersionParser, ) {} + /** + * @param array{from_version?: mixed, from_inclusive?: mixed, to_version?: mixed, to_inclusive?: mixed}[] $data + */ public function parse(array $data): ?ConstraintInterface { $constraints = array_map(function (array $affected): ?string { - $fromVersion = (string) ($affected['from_version'] ?? self::UNKNOWN); + $fromVersion = $affected['from_version'] ?? null; + if (! is_string($fromVersion)) { + return null; + } + $fromInclusive = (bool) ($affected['from_inclusive'] ?? false); - $toVersion = (string) ($affected['to_version'] ?? self::UNKNOWN); + + $toVersion = $affected['to_version'] ?? null; + if (! is_string($toVersion)) { + return null; + } + $toInclusive = (bool) ($affected['to_inclusive'] ?? false); if ($fromVersion === '*' && $toVersion === '*') { @@ -40,7 +50,7 @@ public function parse(array $data): ?ConstraintInterface } if ($toVersion !== '*') { - if (! empty($constraint)) { + if ($constraint !== '') { $constraint .= ', '; } @@ -51,23 +61,18 @@ public function parse(array $data): ?ConstraintInterface return $constraint; }, $data); - $constraints = array_filter($constraints); + $constraints = array_filter($constraints, static fn (?string $c) => $c !== null); + $constraints = array_filter($constraints, static fn (string $c) => $c !== ''); $constraints = array_values($constraints); $imploded = implode('||', $constraints); - if (empty($imploded)) { - return null; - } - - return $this->parser->parseConstraints($imploded); + return $imploded === '' + ? null + : $this->parser->parseConstraints($imploded); } private function isValid(string $version): bool { - if ($version === self::UNKNOWN) { - return false; - } - if ($version === '*') { return true; } diff --git a/src/Client.php b/src/Client.php index 5344f12..731f71b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -32,8 +32,13 @@ public function fetch(Feed $feed): Generator throw InvalidJsonException::forFeedResponse($feed); } + /** @var mixed[] $data */ $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); foreach ($data as $datum) { + if (! is_array($datum)) { + continue; + } + $record = $this->recordFactory->make($datum); if ($record !== null) { yield $record; @@ -44,7 +49,10 @@ public function fetch(Feed $feed): Generator private function get(Feed $feed): ResponseInterface { try { - return $this->http->get($feed->url()); + return $this->http->request( + 'GET', + $feed->url(), + ); } catch (TransferException $exception) { // Guzzle throws exceptions for non-2xx responses. throw HttpException::fromResponse($feed, $exception); diff --git a/src/CopyrightFactory.php b/src/CopyrightFactory.php index 3a990bc..444fa98 100644 --- a/src/CopyrightFactory.php +++ b/src/CopyrightFactory.php @@ -9,7 +9,7 @@ class CopyrightFactory /** @var Copyright[] */ private array $cache = []; - public function make(mixed $data): ?Copyright + public function make(mixed $data): ?Copyright // TODO! { if (! is_array($data)) { return null; @@ -24,11 +24,23 @@ public function make(mixed $data): ?Copyright return $copyright; } - $notice = (string) ($data['notice'] ?? ''); - $license = (string) ($data['license'] ?? ''); - $licenseUrl = (string) ($data['license_url'] ?? ''); + $notice = $data['notice'] ?? ''; + if (! is_string($notice)) { + $notice = ''; + } + + $license = $data['license'] ?? ''; + if (! is_string($license)) { + $license = ''; + } + + $licenseUrl = $data['license_url'] ?? ''; + if (! is_string($licenseUrl)) { + $licenseUrl = ''; + } - if (empty($notice) && empty($license) && empty($licenseUrl)) { + // Must have at least one field. + if ($notice === '' && $license === '' && $licenseUrl === '') { return null; } diff --git a/src/CvssFactory.php b/src/CvssFactory.php index cb5fb51..655869a 100644 --- a/src/CvssFactory.php +++ b/src/CvssFactory.php @@ -6,13 +6,30 @@ readonly class CvssFactory { + /** + * @param array{vector?: mixed, score?: mixed, rating?: mixed} $data + */ public function make(array $data): ?Cvss { - $vector = (string) ($data['vector'] ?? ''); - $score = (string) ($data['score'] ?? ''); - $rating = CvssRating::tryFrom($data['rating'] ?? ''); + $vector = $data['vector'] ?? null; + if (! is_string($vector) || $vector === '') { + return null; + } + + $score = $data['score'] ?? null; + if (is_int($score) || is_float($score)) { + $score = (string) $score; + } + if (! is_string($score) || $score === '' || $score === '0') { + return null; + } - if (empty($vector) || empty($score) || $rating === null) { + $rawRating = $data['rating'] ?? null; + if (! is_string($rawRating) || $rawRating === '') { + return null; + } + $rating = CvssRating::tryFrom($rawRating); + if ($rating === null) { return null; } diff --git a/src/Feed.php b/src/Feed.php index 907467e..7cd633c 100644 --- a/src/Feed.php +++ b/src/Feed.php @@ -9,7 +9,7 @@ enum Feed case Production; case Scanner; - public function label(): string + public function label(): string // TODO! { return match ($this) { self::Production => 'production', diff --git a/src/Record.php b/src/Record.php index 5f42abb..2a0e477 100644 --- a/src/Record.php +++ b/src/Record.php @@ -10,6 +10,7 @@ { /** * @param Software[] $software + * @param string[] $references * @param Copyright[] $copyrights */ public function __construct( diff --git a/src/RecordFactory.php b/src/RecordFactory.php index d0580c4..b76a1b3 100644 --- a/src/RecordFactory.php +++ b/src/RecordFactory.php @@ -6,7 +6,6 @@ use DateTimeImmutable; use DateTimeZone; -use Exception; // TODO: Mark as `readonly` when Mockery supports it. // See: https://github.com/mockery/mockery/issues/1317 @@ -18,20 +17,32 @@ public function __construct( private readonly CvssFactory $cvssFactory = new CvssFactory, ) {} + /** + * @param array{ + * id?: mixed, + * title?: mixed, + * software?: mixed, + * references?: mixed, + * copyrights?: mixed, + * cve?: mixed, + * cvss?: mixed, + * published?: mixed + * } $data + */ public function make(array $data): ?Record { - $id = (string) ($data['id'] ?? ''); - if (empty($id)) { + $id = $data['id'] ?? ''; + if (! is_string($id) || $id === '') { return null; } - $title = (string) ($data['title'] ?? ''); - if (empty($title)) { + $title = $data['title'] ?? ''; + if (! is_string($title) || $title === '') { return null; } $software = $this->makeSoftware($data); - if (empty($software)) { + if ($software === []) { return null; } @@ -39,8 +50,8 @@ public function make(array $data): ?Record $copyrights = $this->makeCopyrights($data); - $cve = (string) ($data['cve'] ?? ''); - if (empty($cve)) { + $cve = $data['cve'] ?? null; + if (! is_string($cve) || $cve === '') { $cve = null; } @@ -63,13 +74,20 @@ public function make(array $data): ?Record } /** + * @param array{software?: mixed} $data * @return Software[] */ private function makeSoftware(array $data): array { + $rawSoftwares = $data['software'] ?? null; + if (! is_array($rawSoftwares)) { + return []; + } + $rawSoftwares = array_filter($rawSoftwares, static fn (mixed $s) => is_array($s)); + $softwares = array_map( fn (array $datum): ?Software => $this->softwareFactory->make($datum), - (array) ($data['software'] ?? []), + $rawSoftwares, ); $softwares = array_filter($softwares); @@ -77,19 +95,20 @@ private function makeSoftware(array $data): array } /** + * @param array{references?: mixed} $data * @return string[] */ private function makeReferences(array $data): array { $references = (array) ($data['references'] ?? []); - $references = array_filter($references, static fn (mixed $r): bool => is_string($r)); - $references = array_filter($references); - $references = array_values($references); + $references = array_filter($references, static fn (mixed $r) => is_string($r)); + $references = array_filter($references, static fn (string $r) => $r !== ''); - return empty($references) ? [] : $references; + return array_values($references); } /** + * @param array{copyrights?: mixed} $data * @return Copyright[] */ private function makeCopyrights(array $data): array @@ -103,22 +122,22 @@ private function makeCopyrights(array $data): array return array_values($copyrights); } + /** + * @param array{published?: mixed} $data + */ private function makePublished(array $data): ?DateTimeImmutable { - static $utc; - if (! isset($utc)) { - $utc = new DateTimeZone('UTC'); - } - - $datum = (string) ($data['published'] ?? ''); - if (empty($datum)) { + $datum = $data['published'] ?? null; + if (! is_string($datum) || $datum === '') { return null; } - try { - return DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $datum, $utc); - } catch (Exception) { - return null; - } + $datetime = DateTimeImmutable::createFromFormat( + 'Y-m-d H:i:s', + $datum, + new DateTimeZone('UTC'), + ); + + return $datetime === false ? null : $datetime; } } diff --git a/src/SoftwareFactory.php b/src/SoftwareFactory.php index 45d68b8..7e21897 100644 --- a/src/SoftwareFactory.php +++ b/src/SoftwareFactory.php @@ -10,21 +10,31 @@ public function __construct( private AffectedVersionsParser $affectedVersionsParser = new AffectedVersionsParser, ) {} + /** + * @param array{slug?: mixed, type?: mixed, affected_versions?: mixed} $data + */ public function make(array $data): ?Software { - $slug = (string) ($data['slug'] ?? ''); - if (empty($slug)) { + $slug = $data['slug'] ?? null; + if (! is_string($slug) || $slug === '') { return null; } - $type = SoftwareType::tryFrom($data['type'] ?? ''); + $rawType = $data['type'] ?? null; + if (! is_string($rawType) || $rawType === '') { + return null; + } + $type = SoftwareType::tryFrom($rawType); if ($type === null) { return null; } - $affectedVersions = $this->affectedVersionsParser->parse( - (array) ($data['affected_versions'] ?? []) - ); + $rawAffectedVersions = $data['affected_versions'] ?? null; + if (! is_array($rawAffectedVersions)) { + return null; + } + $rawAffectedVersions = array_filter($rawAffectedVersions, 'is_array'); + $affectedVersions = $this->affectedVersionsParser->parse($rawAffectedVersions); if ($affectedVersions === null) { return null; }