├── tests ├── _data │ └── .gitkeep ├── _output │ └── .gitignore ├── _support │ ├── _generated │ │ └── .gitignore │ ├── Helper │ │ └── Unit.php │ └── UnitTester.php ├── unit.suite.yml └── unit │ ├── DownloadExceptionTest.php │ ├── StaleRulesExceptionTest.php │ ├── InvalidVersionExceptionTest.php │ ├── UnknownVersionExceptionTest.php │ ├── LoggerTest.php │ ├── ParseExceptionTest.php │ ├── CveDetailsTest.php │ ├── CliTest.php │ ├── CveIdTest.php │ ├── DateHelpersTest.php │ ├── RulesTest.php │ ├── PhpReleaseTest.php │ ├── PhpVersionTest.php │ └── ApplicationTest.php ├── .gitignore ├── docs ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── fonts │ ├── Inter-Black.woff │ ├── Inter-Black.woff2 │ ├── Inter-Bold.woff │ ├── Inter-Bold.woff2 │ ├── Inter-Italic.woff │ ├── Inter-Light.woff │ ├── Inter-Light.woff2 │ ├── Inter-Medium.woff │ ├── Inter-Thin.woff │ ├── Inter-Thin.woff2 │ ├── Inter.var.woff2 │ ├── Inter-Italic.woff2 │ ├── Inter-Medium.woff2 │ ├── Inter-Regular.woff │ ├── Inter-Regular.woff2 │ ├── Inter-SemiBold.woff │ ├── Inter-BlackItalic.woff │ ├── Inter-BoldItalic.woff │ ├── Inter-BoldItalic.woff2 │ ├── Inter-ExtraBold.woff │ ├── Inter-ExtraBold.woff2 │ ├── Inter-ExtraLight.woff │ ├── Inter-ExtraLight.woff2 │ ├── Inter-LightItalic.woff │ ├── Inter-SemiBold.woff2 │ ├── Inter-ThinItalic.woff │ ├── Inter-ThinItalic.woff2 │ ├── Inter-italic.var.woff2 │ ├── Inter-roman.var.woff2 │ ├── Inter-BlackItalic.woff2 │ ├── Inter-LightItalic.woff2 │ ├── Inter-MediumItalic.woff │ ├── Inter-MediumItalic.woff2 │ ├── Inter-ExtraBoldItalic.woff │ ├── Inter-ExtraBoldItalic.woff2 │ ├── Inter-ExtraLightItalic.woff │ ├── Inter-SemiBoldItalic.woff │ ├── Inter-SemiBoldItalic.woff2 │ ├── Inter-ExtraLightItalic.woff2 │ └── inter.css ├── php-version-audit-logo.png ├── lightswitch05.svg ├── index.css ├── stats.html ├── php-version-audit-logo.svg ├── index.html └── php-version-audit-logo-inkscape.svg ├── .github ├── dependabot.yml └── workflows │ ├── dependency-review.yml │ ├── tests.yml │ └── auto-updates.yml ├── docker ├── Dockerfile.composer ├── docker-entrypoint.sh ├── Dockerfile.dev ├── Dockerfile.alpine ├── Dockerfile.bookworm └── Dockerfile.bullseye ├── .editorconfig ├── src ├── Exceptions │ ├── DownloadException.php │ ├── InvalidArgumentException.php │ ├── InvalidVersionException.php │ ├── StaleRulesException.php │ ├── UnknownVersionException.php │ └── ParseException.php ├── CveDetails.php ├── CveId.php ├── Logger.php ├── Parsers │ ├── ChangelogParser.php │ ├── SupportParser.php │ └── NvdFeedParser.php ├── DateHelpers.php ├── PhpRelease.php ├── PhpVersion.php ├── Rules.php ├── Cli.php ├── CachedDownload.php └── Application.php ├── codeception.yml ├── sonar-project.properties ├── psalm.xml ├── rector.php ├── php-version-audit ├── ecs.php ├── Makefile ├── composer.json ├── docker-compose.yml ├── tag-and-push-images.sh ├── github-commit-auto-updates.sh ├── cron-job.sh ├── LICENSE └── README.md /tests/_data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/_output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /tests/_support/_generated/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | vendor 4 | tmp 5 | *.log 6 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /docs/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/favicon-16x16.png -------------------------------------------------------------------------------- /docs/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/favicon-32x32.png -------------------------------------------------------------------------------- /docs/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/fonts/Inter-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Black.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Black.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Bold.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Bold.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Italic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Light.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Light.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Medium.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Thin.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Thin.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter.var.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Italic.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Medium.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Regular.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-Regular.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-SemiBold.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-BlackItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-BlackItalic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-BoldItalic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-BoldItalic.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-ExtraBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ExtraBold.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ExtraBold.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-ExtraLight.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ExtraLight.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-ExtraLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ExtraLight.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-LightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-LightItalic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-SemiBold.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-ThinItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ThinItalic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ThinItalic.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-italic.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-italic.var.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-roman.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-roman.var.woff2 -------------------------------------------------------------------------------- /docs/php-version-audit-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/php-version-audit-logo.png -------------------------------------------------------------------------------- /docs/fonts/Inter-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-BlackItalic.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-LightItalic.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-MediumItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-MediumItalic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-MediumItalic.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-ExtraBoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ExtraBoldItalic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-ExtraBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ExtraBoldItalic.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-ExtraLightItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ExtraLightItalic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-SemiBoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-SemiBoldItalic.woff -------------------------------------------------------------------------------- /docs/fonts/Inter-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /docs/fonts/Inter-ExtraLightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lightswitch05/php-version-audit/HEAD/docs/fonts/Inter-ExtraLightItalic.woff2 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | timezone: Canada/Eastern 9 | open-pull-requests-limit: 10 10 | -------------------------------------------------------------------------------- /docker/Dockerfile.composer: -------------------------------------------------------------------------------- 1 | FROM php:8.3-cli-bookworm 2 | COPY --from=composer/composer:2.7-bin /composer /usr/bin/composer 3 | RUN apt-get update && apt-get install -y libzip4 libzip-dev && docker-php-ext-install zip 4 | ENTRYPOINT ["/usr/bin/composer"] 5 | -------------------------------------------------------------------------------- /tests/unit.suite.yml: -------------------------------------------------------------------------------- 1 | # Codeception Test Suite Configuration 2 | # 3 | # Suite for unit or integration tests. 4 | 5 | actor: UnitTester 6 | modules: 7 | enabled: 8 | - Asserts 9 | - \Helper\Unit 10 | step_decorators: ~ -------------------------------------------------------------------------------- /tests/_support/Helper/Unit.php: -------------------------------------------------------------------------------- 1 | /tmp/update-ca-certificates.log 9 | fi 10 | 11 | /opt/php-version-audit/php-version-audit "$@" 12 | -------------------------------------------------------------------------------- /src/Exceptions/StaleRulesException.php: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Exceptions/ParseException.php: -------------------------------------------------------------------------------- 1 | getMessage(), $ex->getCode(), 1, $fileName, $line, $ex); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM php:8.3-cli-alpine 2 | WORKDIR /opt/php-version-audit 3 | 4 | RUN apk --update --no-cache add libzip-dev autoconf g++ make linux-headers && \ 5 | cp "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" && \ 6 | pecl install -f xdebug && \ 7 | docker-php-ext-enable xdebug && \ 8 | pecl clear-cache && \ 9 | echo "xdebug.mode=coverage" >> "$PHP_INI_DIR/conf.d/docker-php-ext-xdebug.ini" && \ 10 | apk del --purge autoconf g++ make 11 | 12 | ENV REQUIRE_VERSION_ARG=true 13 | 14 | ENTRYPOINT ["/opt/php-version-audit/docker/docker-entrypoint.sh"] 15 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | paths([ 11 | __DIR__ . '/src', 12 | __DIR__ . '/tests', 13 | ]); 14 | 15 | // register a single rule 16 | $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); 17 | 18 | // define sets of rules 19 | $rectorConfig->sets([ 20 | LevelSetList::UP_TO_PHP_80 21 | ]); 22 | }; 23 | -------------------------------------------------------------------------------- /php-version-audit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/_support/UnitTester.php: -------------------------------------------------------------------------------- 1 | assertNotNull($exception); 11 | $this->assertEquals('Download error: exception message', $exception->getMessage()); 12 | } 13 | 14 | public function testItCreatesFromNull() 15 | { 16 | $exception = DownloadException::fromString(null); 17 | $this->assertNotNull($exception); 18 | $this->assertEquals('Download error: ', $exception->getMessage()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/unit/StaleRulesExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertNotNull($exception); 11 | $this->assertEquals('Rules are stale: stale rules', $exception->getMessage()); 12 | } 13 | 14 | public function testItCreatesFromNull() 15 | { 16 | $exception = StaleRulesException::fromString(null); 17 | $this->assertNotNull($exception); 18 | $this->assertEquals('Rules are stale: ', $exception->getMessage()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/unit/InvalidVersionExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertNotNull($exception); 11 | $this->assertEquals('PhpVersion [bad version] is not valid', $exception->getMessage()); 12 | } 13 | 14 | public function testItCreatesFromNull() 15 | { 16 | $exception = InvalidVersionException::fromString(null); 17 | $this->assertNotNull($exception); 18 | $this->assertEquals('PhpVersion [] is not valid', $exception->getMessage()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/unit/UnknownVersionExceptionTest.php: -------------------------------------------------------------------------------- 1 | assertNotNull($exception); 11 | $this->assertEquals('Unknown PHP version (1.2.3), perhaps rules are stale.', $exception->getMessage()); 12 | } 13 | 14 | public function testItCreatesFromNull() 15 | { 16 | $exception = UnknownVersionException::fromString(null); 17 | $this->assertNotNull($exception); 18 | $this->assertEquals('Unknown PHP version (), perhaps rules are stale.', $exception->getMessage()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 11 | __DIR__ . '/src', 12 | ]); 13 | 14 | // this way you add a single rule 15 | $ecsConfig->rules([ 16 | NoUnusedImportsFixer::class, 17 | ]); 18 | 19 | // this way you can add sets - group of rules 20 | $ecsConfig->sets([ 21 | // run and fix, one by one 22 | SetList::ARRAY, 23 | SetList::DOCBLOCK, 24 | SetList::NAMESPACES, 25 | SetList::COMMENTS, 26 | SetList::PSR_12, 27 | SetList::STRICT, 28 | SetList::DOCTRINE_ANNOTATIONS, 29 | SetList::CLEAN_CODE, 30 | ]); 31 | }; 32 | -------------------------------------------------------------------------------- /docker/Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | FROM php:8.3-cli-alpine AS composer-build 2 | COPY --from=composer/composer:2.7-bin /composer /usr/bin/composer 3 | WORKDIR /opt/php-version-audit 4 | COPY ./docker/docker-entrypoint.sh ./docker/docker-entrypoint.sh 5 | COPY ./src ./src 6 | COPY ./docs/rules-v1.json ./docs/rules-v1.json 7 | COPY php-version-audit . 8 | COPY ./composer.* . 9 | RUN composer install \ 10 | --classmap-authoritative \ 11 | --no-dev \ 12 | --no-interaction \ 13 | --no-progress \ 14 | --no-suggest \ 15 | --optimize-autoloader \ 16 | --prefer-dist 17 | 18 | FROM php:8.3-cli-alpine 19 | WORKDIR /opt/php-version-audit 20 | ENV REQUIRE_VERSION_ARG=true 21 | COPY --from=composer-build /opt/php-version-audit /opt/php-version-audit 22 | ENTRYPOINT ["/opt/php-version-audit/docker/docker-entrypoint.sh"] 23 | -------------------------------------------------------------------------------- /docker/Dockerfile.bookworm: -------------------------------------------------------------------------------- 1 | FROM php:8.3-cli-bookworm AS composer-build 2 | COPY --from=composer/composer:2.7-bin /composer /usr/bin/composer 3 | WORKDIR /opt/php-version-audit 4 | COPY ./docker/docker-entrypoint.sh ./docker/docker-entrypoint.sh 5 | COPY ./src ./src 6 | COPY ./docs/rules-v1.json ./docs/rules-v1.json 7 | COPY php-version-audit . 8 | COPY ./composer.* . 9 | RUN composer install \ 10 | --classmap-authoritative \ 11 | --no-dev \ 12 | --no-interaction \ 13 | --no-progress \ 14 | --no-suggest \ 15 | --optimize-autoloader \ 16 | --prefer-dist 17 | 18 | FROM php:8.3-cli-bookworm 19 | WORKDIR /opt/php-version-audit 20 | ENV REQUIRE_VERSION_ARG=true 21 | COPY --from=composer-build /opt/php-version-audit /opt/php-version-audit 22 | ENTRYPOINT ["/opt/php-version-audit/docker/docker-entrypoint.sh"] 23 | -------------------------------------------------------------------------------- /docker/Dockerfile.bullseye: -------------------------------------------------------------------------------- 1 | FROM php:8.3-cli-bullseye AS composer-build 2 | COPY --from=composer/composer:2.7-bin /composer /usr/bin/composer 3 | WORKDIR /opt/php-version-audit 4 | COPY ./docker/docker-entrypoint.sh ./docker/docker-entrypoint.sh 5 | COPY ./src ./src 6 | COPY ./docs/rules-v1.json ./docs/rules-v1.json 7 | COPY php-version-audit . 8 | COPY ./composer.* . 9 | RUN composer install \ 10 | --classmap-authoritative \ 11 | --no-dev \ 12 | --no-interaction \ 13 | --no-progress \ 14 | --no-suggest \ 15 | --optimize-autoloader \ 16 | --prefer-dist 17 | 18 | FROM php:8.3-cli-bullseye 19 | WORKDIR /opt/php-version-audit 20 | ENV REQUIRE_VERSION_ARG=true 21 | COPY --from=composer-build /opt/php-version-audit /opt/php-version-audit 22 | ENTRYPOINT ["/opt/php-version-audit/docker/docker-entrypoint.sh"] 23 | -------------------------------------------------------------------------------- /src/CveDetails.php: -------------------------------------------------------------------------------- 1 | id; 21 | } 22 | 23 | 24 | public function jsonSerialize(): array 25 | { 26 | return [ 27 | "id" => $this->id, 28 | "baseScore" => $this->baseScore, 29 | "publishedDate" => $this->publishedDate, 30 | "lastModifiedDate" => $this->lastModifiedDate, 31 | "description" => $this->description, 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Reqest, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v4 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: tests 2 | 3 | setup: 4 | @docker compose pull --ignore-pull-failures 5 | @docker compose run --rm composer install 6 | 7 | tests: 8 | @docker compose run --rm composer validate --strict 9 | @docker compose run --rm php vendor/bin/codecept run --coverage --coverage-html --phpunit-xml test-results.xml --coverage-xml coverage.xml --steps 10 | 11 | run: 12 | @docker compose run --rm php-version-audit 13 | 14 | lint: phpstan psalm rector-dry ecs-dry 15 | lint-fix: phpstan psalm rector ecs 16 | 17 | phpstan: 18 | @docker compose run --rm phpstan 19 | 20 | psalm: 21 | @docker compose run --rm --entrypoint=./vendor/bin/psalm php 22 | 23 | rector-dry: 24 | @docker compose run --rm --entrypoint vendor/bin/rector php process src --dry-run 25 | 26 | rector: 27 | @docker compose run --rm --entrypoint vendor/bin/rector php process src 28 | 29 | ecs-dry: 30 | @docker compose run --rm --entrypoint vendor/bin/ecs php 31 | 32 | ecs: 33 | @docker compose run --rm --entrypoint vendor/bin/ecs php --fix 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lightswitch05/php-version-audit", 3 | "bin": "php-version-audit", 4 | "license": "Apache-2.0", 5 | "description": "A convenience tool to easily check a given PHP version against a regularly updated list of CVE exploits, new releases, and end of life dates", 6 | "keywords": ["security", "audit", "vulnerability", "version", "php", "scanner", "cve"], 7 | "autoload": { 8 | "psr-4": { 9 | "lightswitch05\\PhpVersionAudit\\": "src/" 10 | } 11 | }, 12 | "require": { 13 | "php": ">=8.2.0", 14 | "ext-json": "*", 15 | "ext-curl": "*" 16 | }, 17 | "require-dev": { 18 | "ext-libxml": "*", 19 | "ext-dom": "*", 20 | "ext-zlib": "*", 21 | "ext-zip": "*", 22 | "codeception/codeception": "^5.1", 23 | "vimeo/psalm": "^5.25", 24 | "codeception/module-asserts": "^3.0", 25 | "rector/rector": "^1.2.3", 26 | "symplify/easy-coding-standard": "^12.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/unit/LoggerTest.php: -------------------------------------------------------------------------------- 1 | assertNotNull($exception); 11 | $this->assertEquals('Parse error: exception message', $exception->getMessage()); 12 | } 13 | 14 | public function testItCreatesFromNull() 15 | { 16 | $exception = ParseException::fromString(null); 17 | $this->assertNotNull($exception); 18 | $this->assertEquals('Parse error: ', $exception->getMessage()); 19 | } 20 | 21 | public function testItCreatesFromException() 22 | { 23 | $ex = new Exception('Test Exception'); 24 | $parseException = ParseException::fromException($ex, 'filename.php', 500); 25 | $this->assertNotNull($parseException); 26 | $this->assertEquals('Test Exception', $parseException->getMessage()); 27 | $this->assertEquals(500, $parseException->getLine()); 28 | $this->assertEquals('filename.php', $parseException->getFile()); 29 | $this->assertEquals($ex, $parseException->getPrevious()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | x-common: &common 3 | volumes: 4 | - "${PWD}:/opt/php-version-audit" 5 | working_dir: /opt/php-version-audit 6 | 7 | services: 8 | composer: 9 | <<: *common 10 | build: 11 | context: ./ 12 | dockerfile: ./docker/Dockerfile.composer 13 | volumes: 14 | - "${PWD}:/opt/php-version-audit" 15 | - "${HOME}/.composer:/tmp" 16 | 17 | php: 18 | <<: *common 19 | build: 20 | context: ./ 21 | dockerfile: ./docker/Dockerfile.dev 22 | entrypoint: php 23 | environment: 24 | REQUIRE_VERSION_ARG: "false" 25 | 26 | php-version-audit: 27 | <<: *common 28 | build: 29 | context: ./ 30 | dockerfile: ./docker/Dockerfile.dev 31 | environment: 32 | REQUIRE_VERSION_ARG: "false" 33 | 34 | phpstan: 35 | <<: *common 36 | image: ghcr.io/phpstan/phpstan:1-php8.3 37 | command: analyse ./src 38 | 39 | alpine: 40 | build: 41 | context: ./ 42 | dockerfile: ./docker/Dockerfile.alpine 43 | image: lightswitch05/php-version-audit:alpine 44 | 45 | bullseye: 46 | build: 47 | context: ./ 48 | dockerfile: ./docker/Dockerfile.bullseye 49 | image: lightswitch05/php-version-audit:bullseye 50 | 51 | bookworm: 52 | build: 53 | context: ./ 54 | dockerfile: ./docker/Dockerfile.bookworm 55 | image: lightswitch05/php-version-audit:bookworm 56 | -------------------------------------------------------------------------------- /tag-and-push-images.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Usage: ./tag-and-push-images.sh ${docker-compose-service-name} 4 | # examples: 5 | # ./tag-and-push-images.sh alpine 6 | # ./tag-and-push-images.sh bookworm 7 | # 8 | function main() { 9 | target="${1}" 10 | DEFAULT_TAG="bookworm" 11 | MAJOR_VERSION="1" 12 | IMAGE="lightswitch05/php-version-audit" 13 | 14 | # Build and tag primary tag name 15 | echo "Building ${target}" 16 | docker compose build --pull "${target}" 17 | echo "Pushing ${IMAGE}:${target}" 18 | docker push "${IMAGE}:${target}" 19 | 20 | # Version-based tag name with OS 21 | echo "Tagging ${IMAGE}:${target} as ${IMAGE}:${MAJOR_VERSION}-${target}" 22 | docker tag "${IMAGE}:${target}" "${IMAGE}:${MAJOR_VERSION}-${target}" 23 | echo "Pushing ${IMAGE}:${MAJOR_VERSION}-${target}" 24 | docker push "${IMAGE}:${MAJOR_VERSION}-${target}" 25 | 26 | # Latest tag name & version-only tag 27 | if [[ "${target}" = "${DEFAULT_TAG}" ]]; then 28 | echo "Tagging ${IMAGE}:${target}" "${IMAGE}:latest" 29 | docker tag "${IMAGE}:${target}" "${IMAGE}:latest" 30 | echo "Pushing ${IMAGE}:latest" 31 | docker push "${IMAGE}:latest" 32 | 33 | # Version-based tag name with OS 34 | echo "Tagging ${IMAGE}:${target} as ${IMAGE}:${MAJOR_VERSION}" 35 | docker tag "${IMAGE}:${target}" "${IMAGE}:${MAJOR_VERSION}" 36 | echo "Pushing ${IMAGE}:${MAJOR_VERSION}" 37 | docker push "${IMAGE}:${MAJOR_VERSION}" 38 | fi 39 | } 40 | 41 | main "$@" 42 | -------------------------------------------------------------------------------- /tests/unit/CveDetailsTest.php: -------------------------------------------------------------------------------- 1 | assertNotNull($cve); 21 | $this->assertEquals(self::$CVE_ID, $cve->getId()); 22 | $this->assertEquals(json_encode([ 23 | "id" => self::CVE_ID, 24 | "baseScore" => null, 25 | "publishedDate" => null, 26 | "lastModifiedDate" => null, 27 | "description" => null 28 | ]), json_encode($cve)); 29 | } 30 | 31 | public function testFullConstruction() 32 | { 33 | $cve = new CveDetails(self::$CVE_ID, 5.5, "2016-05-22T01:59:00+0000", "2018-10-30T16:27:00+0000", 'description'); 34 | $this->assertNotNull($cve); 35 | $this->assertEquals(self::$CVE_ID, $cve->getId()); 36 | $this->assertEquals(json_encode([ 37 | "id" => self::CVE_ID, 38 | "baseScore" => 5.5, 39 | "publishedDate" => '2016-05-22T01:59:00+0000', 40 | "lastModifiedDate" => '2018-10-30T16:27:00+0000', 41 | "description" => 'description' 42 | ]), json_encode($cve)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /github-commit-auto-updates.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | 4 | cat <<- EOF > "${HOME}/.netrc" 5 | machine github.com 6 | login $GITHUB_ACTOR 7 | password $GITHUB_TOKEN 8 | machine api.github.com 9 | login $GITHUB_ACTOR 10 | password $GITHUB_TOKEN 11 | EOF 12 | chmod 600 "${HOME}/.netrc" 13 | git config --global user.email "daniel@developerdan.com" 14 | git config --global user.name "Auto Updates" 15 | 16 | COMMIT_MESSAGE="Automatic github actions updates." 17 | LINES_ADDED=$(git diff --numstat docs/rules-v1.json | sed 's/^\([0-9]*\)\(.*\)/\1/g') 18 | if [ "$LINES_ADDED" -gt "1" ]; then 19 | COMMIT_MESSAGE="${COMMIT_MESSAGE} Changes found @lightswitch05" 20 | fi 21 | 22 | git add ./docs/rules-v1.json 23 | git commit -m "${COMMIT_MESSAGE}" 24 | LAST_TAG=$(git tag -l --sort=v:refname | tail -1) 25 | echo "Last tag: ${LAST_TAG}" 26 | MAJOR_VERSION="${LAST_TAG%%.*}" 27 | echo "Major version: ${MAJOR_VERSION}" 28 | 29 | OLD_MINOR_VERSION="${LAST_TAG%.*}" 30 | OLD_MINOR_VERSION="${OLD_MINOR_VERSION##*.}" 31 | echo "Old Minor version: ${OLD_MINOR_VERSION}" 32 | MINOR_VERSION=$(date +"%Y%m%d") 33 | echo "New Minor version: ${MINOR_VERSION}" 34 | 35 | if [[ "${OLD_MINOR_VERSION}" == "${MINOR_VERSION}" ]]; then 36 | PATCH_VERSION="${LAST_TAG##*.}" 37 | PATCH_VERSION="$((PATCH_VERSION+1))" 38 | else 39 | PATCH_VERSION="0" 40 | fi 41 | echo "Patch version: ${PATCH_VERSION}" 42 | 43 | NEW_TAG="${MAJOR_VERSION}.${MINOR_VERSION}.${PATCH_VERSION}" 44 | echo "New tag: ${NEW_TAG}" 45 | git tag "${NEW_TAG}" 46 | git push origin : "${NEW_TAG}" 47 | -------------------------------------------------------------------------------- /cron-job.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | set -e 5 | 6 | SOURCE_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 7 | cd "${SOURCE_DIR}" 8 | 9 | git fetch --all 10 | git fetch --tags 11 | git checkout . 12 | git checkout master 13 | git pull 14 | 15 | # do actual update 16 | docker compose run --rm php --entrypoint="./php-version-audit" --no-update --full-update 17 | 18 | COMMIT_MESSAGE="Automatic updates." 19 | LINES_ADDED=$(git diff --numstat docs/rules-v1.json | sed 's/^\([0-9]*\)\(.*\)/\1/g') 20 | if [ "$LINES_ADDED" -gt "1" ]; then 21 | COMMIT_MESSAGE="${COMMIT_MESSAGE} Changes found @lightswitch05" 22 | fi 23 | 24 | git add ./docs/rules-v1.json 25 | git commit -m "${COMMIT_MESSAGE}" 26 | 27 | LAST_TAG=$(git tag -l --sort=v:refname | tail -1) 28 | echo "Last tag: ${LAST_TAG}" 29 | MAJOR_VERSION="${LAST_TAG%%.*}" 30 | echo "Major version: ${MAJOR_VERSION}" 31 | 32 | OLD_MINOR_VERSION="${LAST_TAG%.*}" 33 | OLD_MINOR_VERSION="${OLD_MINOR_VERSION##*.}" 34 | echo "Old Minor version: ${OLD_MINOR_VERSION}" 35 | MINOR_VERSION=$(date +"%Y%m%d") 36 | echo "New Minor version: ${MINOR_VERSION}" 37 | 38 | if [[ "${OLD_MINOR_VERSION}" == "${MINOR_VERSION}" ]]; then 39 | PATCH_VERSION="${LAST_TAG##*.}" 40 | PATCH_VERSION="$((PATCH_VERSION+1))" 41 | else 42 | PATCH_VERSION="0" 43 | fi 44 | echo "Patch version: ${PATCH_VERSION}" 45 | 46 | NEW_TAG="${MAJOR_VERSION}.${MINOR_VERSION}.${PATCH_VERSION}" 47 | echo "New tag: ${NEW_TAG}" 48 | 49 | git tag "${NEW_TAG}" 50 | git push 51 | git push gitlab 52 | git push origin : "${NEW_TAG}" 53 | git push gitlab : "${NEW_TAG}" 54 | 55 | 56 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | # Trigger analysis when pushing in master or pull requests, and when creating 5 | # a pull request. 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | types: [ opened, synchronize, reopened ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Get composer cache directory 23 | id: composer-cache 24 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 25 | 26 | - name: Cache composer dependencies 27 | uses: actions/cache@v4 28 | with: 29 | path: ${{ steps.composer-cache.outputs.dir }} 30 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 31 | restore-keys: ${{ runner.os }}-composer- 32 | 33 | - name: Install dependencies 34 | run: docker compose run --rm composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader 35 | 36 | - run: make tests 37 | - run: make psalm 38 | - run: make rector-dry 39 | - run: make phpstan 40 | - run: make ecs-dry 41 | 42 | - name: fix code coverage paths 43 | working-directory: ./tests/_output 44 | run: | 45 | sed -i 's/\/opt\/php-version-audit\//\/github\/workspace\//g' test-results.xml 46 | sed -i 's/\/opt\/php-version-audit\//\/github\/workspace\//g' coverage.xml 47 | 48 | - name: SonarCloud Scan 49 | uses: sonarsource/sonarcloud-github-action@master 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 53 | -------------------------------------------------------------------------------- /src/CveId.php: -------------------------------------------------------------------------------- 1 | year = (int) $matches[1]; 17 | $this->sequenceNumber = (int) $matches[2]; 18 | } 19 | 20 | public static function fromString(?string $cveId): ?CveId 21 | { 22 | if (empty($cveId) || !preg_match("#CVE-(\d+)-(\d+)#i", $cveId, $matches)) { 23 | return null; 24 | } 25 | return new CveId(strtoupper($cveId)); 26 | } 27 | 28 | /** 29 | * @param CveId[] $cveIds 30 | * @return CveId[] 31 | */ 32 | public static function sort(array $cveIds): array 33 | { 34 | $sortedCveIds = array_merge([], $cveIds); 35 | usort($sortedCveIds, fn (CveId $first, CveId $second): int => $first->compareTo($second)); 36 | return $sortedCveIds; 37 | } 38 | 39 | public function compareTo(CveId $otherCveId): int 40 | { 41 | if ($this->year !== $otherCveId->year) { 42 | return $this->year - $otherCveId->year; 43 | } 44 | return $this->sequenceNumber - $otherCveId->sequenceNumber; 45 | } 46 | 47 | public function getId(): string 48 | { 49 | return $this->id; 50 | } 51 | 52 | 53 | public function __toString(): string 54 | { 55 | return $this->id; 56 | } 57 | 58 | 59 | public function jsonSerialize(): string 60 | { 61 | return (string)$this; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/unit/CliTest.php: -------------------------------------------------------------------------------- 1 | deleteDir(__DIR__ . self::$tempBackupPath); 13 | $this->renameDir(__DIR__ . self::$tmpPath, __DIR__ . self::$tempBackupPath); 14 | } 15 | 16 | public function _after() 17 | { 18 | $this->deleteDir(__DIR__ . self::$tmpPath); 19 | $this->renameDir(__DIR__ . self::$tempBackupPath, __DIR__ . self::$tmpPath); 20 | } 21 | 22 | public function testRunsNoCache() 23 | { 24 | $exitCode = Cli::run(); 25 | $this->assertEquals(0, $exitCode); 26 | } 27 | 28 | public function testRunsWithCache() 29 | { 30 | // first run builds cache 31 | $exitCode = Cli::run(); 32 | $this->assertEquals(0, $exitCode); 33 | 34 | // second run uses cache 35 | $exitCode = Cli::run(); 36 | $this->assertEquals(0, $exitCode); 37 | } 38 | 39 | private function deleteDir($fullPath) 40 | { 41 | if (!is_dir($fullPath)) { 42 | return; 43 | } 44 | $files = glob($fullPath . '*', GLOB_MARK); 45 | foreach ($files as $file) { 46 | unlink($file); 47 | } 48 | rmdir($fullPath); 49 | } 50 | 51 | public function renameDir($oldFullPath, $newFullPath) 52 | { 53 | if (!is_dir($oldFullPath)) { 54 | return; 55 | } 56 | if (!is_dir($newFullPath)){ 57 | mkdir($newFullPath); 58 | } 59 | $files = glob($oldFullPath . '*', GLOB_MARK); 60 | foreach ($files as $file) { 61 | rename($oldFullPath . basename($file), $newFullPath . basename($file)); 62 | } 63 | $this->deleteDir($oldFullPath); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Logger.php: -------------------------------------------------------------------------------- 1 | $levelName, 55 | 'time' => DateHelpers::nowString(), 56 | 'message' => '', 57 | ]; 58 | foreach ($messageParts as $messagePart) { 59 | if (is_string($messagePart)) { 60 | $logEvent->message .= $messagePart; 61 | } else { 62 | $logEvent->message .= json_encode($messagePart, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); 63 | } 64 | } 65 | fwrite(STDERR, json_encode($logEvent, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /docs/index.css: -------------------------------------------------------------------------------- 1 | :root, :root.darkTheme { 2 | --color: #c9d1d9; 3 | --background-color: #0d1117; 4 | --link-color: #58a6ff; 5 | } 6 | 7 | html { 8 | font-family: "Inter", "system-ui"; 9 | line-height: 1.2; 10 | text-rendering: optimizeLegibility; 11 | } 12 | 13 | body { 14 | color: var(--color); 15 | background-color: var(--background-color); 16 | max-width: 960px; 17 | margin-left: auto; 18 | margin-right: auto; 19 | } 20 | 21 | graph { 22 | margin-left: auto; 23 | margin-right: auto; 24 | width: 80%; 25 | background-color: #161b22; 26 | } 27 | 28 | main { 29 | margin: 0 5px 0 5px; 30 | } 31 | 32 | a { 33 | color: var(--link-color); 34 | text-decoration: none; 35 | } 36 | 37 | a:hover { 38 | text-decoration: underline; 39 | } 40 | 41 | a.noline:hover { 42 | text-decoration: none; 43 | } 44 | 45 | section { 46 | border-color: #30363d; 47 | border-style: solid; 48 | border-width: 1px; 49 | border-radius: 6px; 50 | margin: 0 0 10px 0; 51 | padding: 1px 5px 10px 5px; 52 | } 53 | 54 | code, dl { 55 | padding: 10px; 56 | margin: 10px 10px 0 10px; 57 | font-size: 0.9rem; 58 | font-family: "Courier New", monospace; 59 | line-height: 1.4; 60 | } 61 | 62 | code { 63 | display: block; 64 | overflow: scroll; 65 | white-space: nowrap; 66 | } 67 | 68 | pre { 69 | overflow: scroll; 70 | font-size: 0.9rem; 71 | font-family: "Courier New", monospace; 72 | line-height: 1.4; 73 | } 74 | 75 | li { 76 | margin-bottom: 3px; 77 | } 78 | 79 | li ul { 80 | margin-top: 3px; 81 | } 82 | 83 | .logo { 84 | display: block; 85 | width: 100%; 86 | max-width: 650px; 87 | margin-left: auto; 88 | margin-right: auto; 89 | } 90 | 91 | .avatar { 92 | height: 1.5em; 93 | vertical-align: bottom; 94 | display: inline-block; 95 | } 96 | 97 | footer { 98 | text-align: center; 99 | margin-bottom: 10px; 100 | } 101 | -------------------------------------------------------------------------------- /src/Parsers/ChangelogParser.php: -------------------------------------------------------------------------------- 1 | getElementsByTagName('section') as $sectionTag) { 47 | $versionString = $sectionTag->getAttribute('id'); 48 | $version = PhpVersion::fromString($versionString); 49 | if ($version === null) { 50 | continue; 51 | } 52 | $dateString = trim($sectionTag->getElementsByTagName('time')[0]->getAttribute('datetime')); 53 | $releaseDate = DateHelpers::fromYMDToISO8601($dateString); 54 | $releases[] = PhpRelease::fromReleaseDescription($version, $releaseDate, $sectionTag->textContent); 55 | } 56 | return $releases; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/unit/CveIdTest.php: -------------------------------------------------------------------------------- 1 | assertNotNull($cve); 13 | $this->assertEquals(self::VALID_CVE_ID, $cve->getId()); 14 | $this->assertEquals(self::VALID_CVE_ID, (string) $cve); 15 | $this->assertEquals(json_encode(self::VALID_CVE_ID), json_encode($cve)); 16 | } 17 | 18 | public function testItAcceptsNullCveId() 19 | { 20 | $cve = CveId::fromString(null); 21 | $this->assertNull($cve); 22 | } 23 | 24 | public function testParsesLowerCaseCveId() 25 | { 26 | $cve = CveId::fromString(strtolower(self::VALID_CVE_ID)); 27 | $this->assertNotNull($cve); 28 | $this->assertEquals(self::VALID_CVE_ID, (string) $cve); 29 | } 30 | 31 | public function testItParsesLongCveIds() 32 | { 33 | $longCveId = self::VALID_CVE_ID . '1234'; 34 | $cve = CveId::fromString(strtolower($longCveId)); 35 | $this->assertNotNull($cve); 36 | $this->assertEquals($longCveId, (string) $cve); 37 | } 38 | 39 | public function testItComparesCveIds() 40 | { 41 | $less = 'CVE-2010-1010'; 42 | $greater = 'CVE-2019-1001'; 43 | $lessCve = CveId::fromString($less); 44 | $greaterCve = CveId::fromString($greater); 45 | $this->assertLessThan(0, $lessCve->compareTo($greaterCve)); 46 | $this->assertGreaterThan(0, $greaterCve->compareTo($lessCve)); 47 | } 48 | 49 | public function testItComparesEqualCveIds() 50 | { 51 | $one = CveId::fromString(self::VALID_CVE_ID); 52 | $two = CveId::fromString(self::VALID_CVE_ID); 53 | $this->assertEquals(0, $one->compareTo($two)); 54 | $this->assertEquals(0, $two->compareTo($one)); 55 | } 56 | 57 | public function testItSortsCveIds() 58 | { 59 | $less = CveId::fromString('CVE-2010-1010'); 60 | $greater = CveId::fromString('CVE-2019-1001'); 61 | $sorted = CveId::sort([$greater, $less]); 62 | $this->assertEquals(2, count($sorted)); 63 | $this->assertEquals($sorted[0], $less); 64 | $this->assertEquals($sorted[1], $greater); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/DateHelpers.php: -------------------------------------------------------------------------------- 1 | setTimestamp($date); 22 | } 23 | 24 | public static function fromJMYToISO8601(?string $date): ?string 25 | { 26 | $dateTime = self::fromFormat('j M Y', $date); 27 | if ($dateTime !== null) { 28 | $dateTime = $dateTime->setTime(0, 0, 0); 29 | } 30 | return self::toISO8601($dateTime); 31 | } 32 | 33 | public static function fromYMDToISO8601(?string $date): ?string 34 | { 35 | $dateTime = self::fromFormat('Y-m-d', $date); 36 | if ($dateTime !== null) { 37 | $dateTime = $dateTime->setTime(0, 0, 0); 38 | } 39 | return self::toISO8601($dateTime); 40 | } 41 | 42 | public static function fromCveFormatToISO8601(?string $date): ?string 43 | { 44 | $dateTime = self::fromFormat('Y-m-d\TH:i\Z', $date); 45 | return self::toISO8601($dateTime); 46 | } 47 | 48 | /** 49 | * @psalm-suppress NullableReturnStatement 50 | * @psalm-suppress InvalidNullableReturnType 51 | */ 52 | public static function nowString(): string 53 | { 54 | return self::toISO8601(new \DateTimeImmutable()); 55 | } 56 | 57 | public static function nowTimestamp(): int 58 | { 59 | return (new \DateTimeImmutable())->getTimestamp(); 60 | } 61 | 62 | public static function toISO8601(?\DateTimeImmutable $date): ?string 63 | { 64 | if ($date === null) { 65 | return null; 66 | } 67 | return $date->format(\DateTime::ISO8601); 68 | } 69 | 70 | private static function fromFormat(string $format, ?string $date): ?\DateTimeImmutable 71 | { 72 | if ($date && $newDate = \DateTimeImmutable::createFromFormat($format, $date)) { 73 | return $newDate; 74 | } 75 | return null; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.github/workflows/auto-updates.yml: -------------------------------------------------------------------------------- 1 | name: Auto Updates 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '5 1 * * *' 7 | - cron: '5 13 * * *' 8 | #- cron: '15 */1 * * *' 9 | 10 | jobs: 11 | run-updates: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | ref: master 17 | fetch-depth: 10 18 | token: ${{ secrets.GITHUB_PAT }} 19 | 20 | # Cache for Composer 21 | - name: Get Composer Cache Directory 22 | id: composer-cache 23 | run: | 24 | echo "::set-output name=dir::$(composer config cache-files-dir)" 25 | - uses: actions/cache@v4 26 | with: 27 | path: ${{ steps.composer-cache.outputs.dir }} 28 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-composer- 31 | 32 | # Cache for full update 33 | - uses: actions/cache@v4 34 | with: 35 | path: ${{ github.workspace }}/tmp 36 | key: ${{ runner.os }}-php-version-audit-${{ hashFiles('**/docs/rules-v1.json') }} 37 | restore-keys: | 38 | ${{ runner.os }}-php-version-audit- 39 | 40 | - name: Change origin to bypass gh-pages issues with actions 41 | run: git remote set-url origin https://x-access-token:${{ secrets.GITHUB_PAT }}@github.com/lightswitch05/php-version-audit.git 42 | 43 | - name: Ensure latest commit with tags 44 | run: git fetch; git fetch --tags --all; git checkout master; git pull 45 | 46 | - name: Install dependencies 47 | run: docker compose run --rm composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader 48 | 49 | - name: Run Update 50 | run: docker compose run --rm php-version-audit --full-update --no-update --vvv 51 | 52 | - name: commit updates 53 | run: ./github-commit-auto-updates.sh 54 | 55 | build: 56 | runs-on: ubuntu-latest 57 | needs: run-updates 58 | strategy: 59 | fail-fast: false 60 | matrix: 61 | TARGET: 62 | - alpine 63 | - bullseye 64 | - bookworm 65 | env: 66 | TARGET: ${{matrix.TARGET}} 67 | steps: 68 | - name: Checkout Repo 69 | uses: actions/checkout@v2 70 | - name: Tag and push all images 71 | run: | 72 | docker login --username=${{ secrets.DOCKERHUB_USER }} --password=${{ secrets.DOCKERHUB_PASS }} 73 | ./tag-and-push-images.sh ${TARGET} 74 | 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_PAT }} 77 | -------------------------------------------------------------------------------- /src/PhpRelease.php: -------------------------------------------------------------------------------- 1 | addPatchedCveIds($id); 29 | } 30 | } 31 | } 32 | return $release; 33 | } 34 | 35 | /** 36 | * @param PhpRelease[] $releases 37 | * @return PhpRelease[] 38 | */ 39 | public static function sort(array $releases): array 40 | { 41 | $sortedReleases = array_merge([], $releases); 42 | usort($sortedReleases, fn (PhpRelease $first, PhpRelease $second): int => $first->compareTo($second)); 43 | return $sortedReleases; 44 | } 45 | 46 | private function addPatchedCveIds(CveId $cveId): void 47 | { 48 | for ($i = 0; $i < sizeof($this->patchedCveIds); $i++) { 49 | $comparison = $this->patchedCveIds[$i]->compareTo($cveId); 50 | if ($comparison === 0) { 51 | return; 52 | } 53 | if ($comparison > 0) { 54 | array_splice($this->patchedCveIds, $i, 0, [$cveId]); 55 | return; 56 | } 57 | } 58 | $this->patchedCveIds[] = $cveId; 59 | } 60 | 61 | public function getVersion(): PhpVersion 62 | { 63 | return $this->version; 64 | } 65 | 66 | public function compareTo(PhpRelease $release): int 67 | { 68 | return $this->version->compareTo($release->version); 69 | } 70 | 71 | /** 72 | * @return CveId[] 73 | */ 74 | public function getPatchedCveIds(): array 75 | { 76 | return $this->patchedCveIds; 77 | } 78 | 79 | 80 | public function jsonSerialize(): array 81 | { 82 | return [ 83 | 'releaseDate' => $this->releaseDate, 84 | 'patchedCves' => $this->patchedCveIds, 85 | ]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/unit/DateHelpersTest.php: -------------------------------------------------------------------------------- 1 | assertNull($date); 11 | } 12 | 13 | public function testItParsesFromISO8601ToDate() 14 | { 15 | $date = DateHelpers::fromISO8601("2019-11-30T03:07:32+0000"); 16 | $this->assertInstanceOf('DateTimeImmutable', $date); 17 | } 18 | 19 | public function testItParsesFromRFC7231ToNull() 20 | { 21 | $date = DateHelpers::fromRFC7231(null); 22 | $this->assertNull($date); 23 | } 24 | 25 | public function testItParsesFromRFC7231toDate() 26 | { 27 | $date = DateHelpers::fromRFC7231("Sat, 30 Nov 2019 03:18:39 GMT"); 28 | $this->assertInstanceOf('DateTimeImmutable', $date); 29 | } 30 | 31 | public function testItParsesFromJMYToISO8601WithNull() 32 | { 33 | $date = DateHelpers::fromJMYToISO8601(null); 34 | $this->assertNull($date); 35 | } 36 | 37 | public function testItParsesFromJMYToISO8601WithDate() 38 | { 39 | $date = DateHelpers::fromJMYToISO8601("27 Aug 1986"); 40 | $this->assertEquals('1986-08-27T00:00:00+0000', $date); 41 | } 42 | 43 | public function testItParsesFromYMDToISO8601WithNull() 44 | { 45 | $date = DateHelpers::fromYMDToISO8601(null); 46 | $this->assertNull($date); 47 | } 48 | 49 | public function testItParsesFromYMDToISO8601WithString() 50 | { 51 | $date = DateHelpers::fromYMDToISO8601('2019-12-25'); 52 | $this->assertEquals('2019-12-25T00:00:00+0000', $date); 53 | } 54 | 55 | public function testItParsesFromCveFormatToISO8601WithNull() 56 | { 57 | $date = DateHelpers::fromCveFormatToISO8601(null); 58 | $this->assertNull($date); 59 | } 60 | 61 | public function testItParsesFromCveFormatToISO8601WithDate() 62 | { 63 | $date = DateHelpers::fromCveFormatToISO8601("2019-02-26T14:04Z"); 64 | $this->assertEquals('2019-02-26T14:04:00+0000', $date); 65 | } 66 | 67 | public function testItGetsNowString() 68 | { 69 | $now = DateHelpers::nowString(); 70 | $this->assertStringMatchesFormat("%i-%i-%iT%i:%i:%i+0000", $now); 71 | } 72 | 73 | public function testItGetsNowTimestamp() 74 | { 75 | $expectedNow = (new \DateTime())->getTimestamp(); 76 | $now = DateHelpers::nowTimestamp(); 77 | $elapsed = abs($expectedNow - $now); 78 | $this->assertLessThan(1, $elapsed); 79 | } 80 | 81 | public function testItFormatsToISO8601FromNull() 82 | { 83 | $date = DateHelpers::toISO8601(null); 84 | $this->assertNull($date); 85 | } 86 | 87 | public function testItFormatsToISO8601FromDate() 88 | { 89 | $date = DateHelpers::toISO8601(new DateTimeImmutable()); 90 | $this->assertTrue(is_string($date)); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/unit/RulesTest.php: -------------------------------------------------------------------------------- 1 | (new DateTime())->modify('-2 week')->modify('+1 hour') 27 | ]; 28 | Rules::assertFreshRules($rules); 29 | } 30 | 31 | public function testItAssertsStaleRules() 32 | { 33 | $this->expectException(StaleRulesException::class); 34 | $rules = (object) [ 35 | 'lastUpdatedDate' => (new DateTime())->modify('-2 week')->modify('-1 hour') 36 | ]; 37 | Rules::assertFreshRules($rules); 38 | } 39 | 40 | public function testItLoadsRulesWithoutUpdate() 41 | { 42 | $rules = Rules::loadRules(true); 43 | $this->assertNotNull($rules); 44 | } 45 | 46 | public function testItThrowsOnMissingRules() 47 | { 48 | $this->expectException(StaleRulesException::class); 49 | unlink(__DIR__ . self::$rulesPath); 50 | Rules::loadRules(true); 51 | } 52 | 53 | public function testItLoadsRulesWithUpdate() 54 | { 55 | $rules = Rules::loadRules(false); 56 | $this->assertNotNull($rules); 57 | } 58 | 59 | public function testItEnsuresValidRules() 60 | { 61 | $this->expectException(StaleRulesException::class); 62 | $rules = json_decode(self::$rulesRaw); 63 | $rules->supportEndDates = []; 64 | file_put_contents(__DIR__ . self::$rulesPath, json_encode($rules)); 65 | Rules::loadRules(true); 66 | } 67 | 68 | public function testItSavesRules() 69 | { 70 | $releaseOne = PhpRelease::fromReleaseDescription(PhpVersion::fromString('5.4.0'), '2019-11-28T00:00:00+0000', 'CVE-2019-11043 CVE-2019-11041 CVE-2019-11042'); 71 | $releaseTwo = PhpRelease::fromReleaseDescription(PhpVersion::fromString('7.3.0'), '2019-11-28T00:00:00+0000', ''); 72 | $releaseThree = PhpRelease::fromReleaseDescription(PhpVersion::fromString('7.4.0rc'), '2019-11-28T00:00:00+0000', 'CVE-2019-11041'); 73 | $releaseFour = PhpRelease::fromReleaseDescription(PhpVersion::fromString('7.3.1'), '2019-11-28T00:00:00+0000', ''); 74 | $releaseFive = PhpRelease::fromReleaseDescription(PhpVersion::fromString('7.4.0'), '2019-11-28T00:00:00+0000', ''); 75 | Rules::saveRules([$releaseOne, $releaseTwo, $releaseThree, $releaseFour, $releaseFive], [], []); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/unit/PhpReleaseTest.php: -------------------------------------------------------------------------------- 1 | assertNotEmpty($release); 21 | $this->assertEmpty($release->getPatchedCveIds()); 22 | $this->assertEquals(self::$PHP_VERSION, $release->getVersion()); 23 | $this->assertEquals(json_encode([ 24 | 'releaseDate' => null, 25 | 'patchedCves' => [] 26 | ]), json_encode($release)); 27 | } 28 | 29 | public function testItParsesMultipleCves() 30 | { 31 | $release = PhpRelease::fromReleaseDescription(self::$PHP_VERSION, self::$PHP_RELEASE_DATE, "CVE-2019-1234 CVE-2017-1234 CVE-2018-1234 CVE-2020-1234 CVE-2020-1234"); 32 | $this->assertNotEmpty($release); 33 | $this->assertNotEmpty($release->getPatchedCveIds()); 34 | $this->assertEquals(json_encode([ 35 | 'releaseDate' => self::$PHP_RELEASE_DATE, 36 | 'patchedCves' => [ 37 | 'CVE-2017-1234', 38 | 'CVE-2018-1234', 39 | 'CVE-2019-1234', 40 | 'CVE-2020-1234' 41 | ] 42 | ]), json_encode($release)); 43 | } 44 | 45 | public function testItComparesVersions() 46 | { 47 | $largest = PhpRelease::fromReleaseDescription(PhpVersion::fromString("7.4.0"), null, null); 48 | $smallest = PhpRelease::fromReleaseDescription(PhpVersion::fromString("7.3.13"), null, null); 49 | $this->assertLessThan(0, $smallest->compareTo($largest)); 50 | $this->assertGreaterThan(0, $largest->compareTo($smallest)); 51 | } 52 | 53 | public function testItSorts() 54 | { 55 | $largest = PhpRelease::fromReleaseDescription(PhpVersion::fromString("7.4.0"), null, null); 56 | $smallest = PhpRelease::fromReleaseDescription(PhpVersion::fromString("7.3.13"), null, null); 57 | $sorted = PhpRelease::sort([$largest, $smallest]); 58 | $this->assertNotEmpty($sorted); 59 | $this->assertEquals((string) $smallest->getVersion(), (string) $sorted[0]->getVersion()); 60 | $this->assertEquals((string) $largest->getVersion(), (string) $sorted[1]->getVersion()); 61 | } 62 | 63 | public function testItRemovesDuplicateCves() 64 | { 65 | $release = PhpRelease::fromReleaseDescription(self::$PHP_VERSION, self::$PHP_RELEASE_DATE, "CVE-2023-1234 CVE-2018-1234 CVE-2023-1234 CVE-2018-1234"); 66 | $this->assertNotEmpty($release); 67 | $this->assertNotEmpty($release->getPatchedCveIds()); 68 | $this->assertEquals(json_encode([ 69 | 'releaseDate' => self::$PHP_RELEASE_DATE, 70 | 'patchedCves' => [ 71 | 'CVE-2018-1234', 72 | 'CVE-2023-1234', 73 | ] 74 | ]), json_encode($release)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Parsers/SupportParser.php: -------------------------------------------------------------------------------- 1 | compareTo($secondVersion); 27 | }); 28 | return $supportDates; 29 | } 30 | 31 | /** 32 | * @return \stdClass[] 33 | * @throws \lightswitch05\PhpVersionAudit\Exceptions\DownloadException 34 | * @throws \lightswitch05\PhpVersionAudit\Exceptions\ParseException 35 | */ 36 | private static function parseSupportedVersions(): array 37 | { 38 | $supportDatesByVersion = []; 39 | Logger::info('Beginning Support parse.'); 40 | $dom = CachedDownload::dom('https://www.php.net/supported-versions.php'); 41 | foreach ($dom->getElementsByTagName('tr') as $row) { 42 | $class = strtolower($row->getAttribute('class')); 43 | $cells = $row->getElementsByTagName('td'); 44 | if (!in_array($class, ['security', 'stable'], true) || count($cells) < 6) { 45 | // all the rows we are interested in have either security or stable class names 46 | continue; 47 | } 48 | $version = trim($cells[0]->textContent); 49 | if (PhpVersion::fromString($version . ".0") !== null) { 50 | $activeDate = DateHelpers::fromJMYToISO8601(trim($cells[3]->textContent)); 51 | $securityDate = DateHelpers::fromJMYToISO8601(trim($cells[5]->textContent)); 52 | $supportDatesByVersion[$version] = new \stdClass(); 53 | $supportDatesByVersion[$version]->active = $activeDate; 54 | $supportDatesByVersion[$version]->security = $securityDate; 55 | } 56 | } 57 | return $supportDatesByVersion; 58 | } 59 | 60 | /** 61 | * @return array<\stdClass> 62 | * @throws \lightswitch05\PhpVersionAudit\Exceptions\DownloadException 63 | * @throws \lightswitch05\PhpVersionAudit\Exceptions\ParseException 64 | */ 65 | private static function parseEol(): array 66 | { 67 | $supportDatesByVersion = []; 68 | Logger::info('Beginning EOL parse.'); 69 | $dom = CachedDownload::dom('https://www.php.net/eol.php'); 70 | foreach ($dom->getElementsByTagName('tr') as $row) { 71 | $cells = $row->getElementsByTagName('td'); 72 | if (count($cells) < 4) { 73 | continue; 74 | } 75 | $version = trim($cells[0]->textContent); 76 | if (PhpVersion::fromString($version . ".0") !== null) { 77 | $supportDatesByVersion[$version] = new \stdClass(); 78 | $supportDatesByVersion[$version]->active = null; 79 | $rawDate = strtok($cells[1]->textContent, '('); 80 | $supportDatesByVersion[$version]->security = DateHelpers::fromJMYToISO8601(trim($rawDate)); 81 | } 82 | } 83 | return $supportDatesByVersion; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/unit/PhpVersionTest.php: -------------------------------------------------------------------------------- 1 | assertNotNull($version); 13 | $this->assertEquals((string)$version, $versionString); 14 | $this->assertEquals($version->getMajor(), 7); 15 | $this->assertEquals($version->getMinor(), 3); 16 | $this->assertEquals($version->getPatch(), 12); 17 | $this->assertEquals($version->getMajorMinorVersionString(), "7.3"); 18 | $this->assertFalse($version->isPreRelease()); 19 | $this->assertEquals(json_encode($versionString), json_encode($versionString)); 20 | } 21 | 22 | public function testItParsesNull() 23 | { 24 | $version = PhpVersion::fromString(null); 25 | $this->assertNull($version); 26 | } 27 | 28 | public function testItParsesBetaString() 29 | { 30 | $version = PhpVersion::fromString("7.4.0beta1"); 31 | $this->assertNotNull($version); 32 | $this->assertTrue($version->isPreRelease()); 33 | } 34 | 35 | public function testItParsesAlphaString() 36 | { 37 | $version = PhpVersion::fromString("7.4.0alpha1"); 38 | $this->assertNotNull($version); 39 | } 40 | 41 | public function testItParsesRCString() 42 | { 43 | $version = PhpVersion::fromString("7.4.0RC1"); 44 | $this->assertNotNull($version); 45 | } 46 | 47 | public function testItParsesReleaseCandidateString() 48 | { 49 | $version = PhpVersion::fromString("7.4.0 release candidate 1"); 50 | $this->assertNotNull($version); 51 | } 52 | 53 | public function testItComparesMajorVersion() 54 | { 55 | $this->compareVersions("7.3.12", "6.4.13"); 56 | } 57 | 58 | public function testItComparesMinorVersion() 59 | { 60 | $this->compareVersions("7.4.12", "7.3.12"); 61 | } 62 | 63 | public function testItComparesPatchVersion() 64 | { 65 | $this->compareVersions("7.3.13", "7.3.12"); 66 | } 67 | 68 | public function testItComparesEqual() 69 | { 70 | $one = PhpVersion::fromString("7.3.13"); 71 | $two = PhpVersion::fromString("7.3.13"); 72 | $this->assertEquals(0, $one->compareTo($two)); 73 | $this->assertEquals(0, $two->compareTo($one)); 74 | } 75 | 76 | public function testItConvertsToJson() 77 | { 78 | $version = PhpVersion::fromString("7.3.13"); 79 | $this->assertEquals(json_encode("7.3.13"), json_encode($version)); 80 | } 81 | 82 | public function testItComparesPreRelease() 83 | { 84 | $this->compareVersions("7.4.0", "7.4.0beta1"); 85 | } 86 | 87 | public function testItComparesSamePrerelaseType() 88 | { 89 | $this->compareVersions("7.4.0beta2", "7.4.0beta1"); 90 | } 91 | 92 | public function testItComparesAlphaToBeta() 93 | { 94 | $this->compareVersions("7.4.0beta1", "7.4.0alpha2"); 95 | } 96 | 97 | public function testItComparesAlphaToRc() 98 | { 99 | $this->compareVersions("7.4.0rc1", "7.4.0alpha2"); 100 | } 101 | 102 | public function testItComparesBetaToRc() 103 | { 104 | $this->compareVersions("7.4.0rc1", "7.4.0beta2"); 105 | } 106 | 107 | private function compareVersions(string $largest, string $smallest): void 108 | { 109 | $largestVersion = PhpVersion::fromString($largest); 110 | $smallestVersion = PhpVersion::fromString($smallest); 111 | $this->assertLessThan(0, $smallestVersion->compareTo($largestVersion)); 112 | $this->assertGreaterThan(0, $largestVersion->compareTo($smallestVersion)); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/PhpVersion.php: -------------------------------------------------------------------------------- 1 | major === $otherVersion->major 50 | && $this->minor === $otherVersion->minor 51 | && $this->patch === $otherVersion->patch 52 | && $this->preReleaseType === $otherVersion->preReleaseType 53 | && $this->preReleaseVersion === $otherVersion->preReleaseVersion) { 54 | return 0; 55 | } 56 | if ($this->major !== $otherVersion->major) { 57 | return $this->major - $otherVersion->major; 58 | } 59 | if ($this->minor !== $otherVersion->minor) { 60 | return $this->minor - $otherVersion->minor; 61 | } 62 | if ($this->patch !== $otherVersion->patch) { 63 | return $this->patch - $otherVersion->patch; 64 | } 65 | if ($this->isPreRelease() && !$otherVersion->isPreRelease()) { 66 | return -1; 67 | } 68 | if (!$this->isPreRelease() && $otherVersion->isPreRelease()) { 69 | return 1; 70 | } 71 | // both are prerelease at this point 72 | if ($this->preReleaseType !== $otherVersion->preReleaseType) { 73 | return strcmp($this->preReleaseType, $otherVersion->preReleaseType); 74 | } 75 | return $this->preReleaseVersion - $otherVersion->preReleaseVersion; 76 | } 77 | 78 | private static function normalizeReleaseType(string $parsedReleaseType): ?string 79 | { 80 | $parsedReleaseType = strtolower($parsedReleaseType); 81 | if (in_array($parsedReleaseType, self::PRE_RELEASE_TYPES, true)) { 82 | return $parsedReleaseType; 83 | } 84 | if (preg_match('#release\s*candidate#', $parsedReleaseType)) { 85 | return self::PRE_RELEASE_CANDIDATE; 86 | } 87 | return null; 88 | } 89 | 90 | public function isPreRelease(): bool 91 | { 92 | return !empty($this->preReleaseType); 93 | } 94 | 95 | public function getMajor(): int 96 | { 97 | return $this->major; 98 | } 99 | 100 | public function getMinor(): int 101 | { 102 | return $this->minor; 103 | } 104 | 105 | public function getPatch(): int 106 | { 107 | return $this->patch; 108 | } 109 | 110 | public function getMajorMinorVersionString(): string 111 | { 112 | return "$this->major.$this->minor"; 113 | } 114 | 115 | 116 | public function __toString(): string 117 | { 118 | return "$this->major.$this->minor.$this->patch$this->preReleaseType$this->preReleaseVersion"; 119 | } 120 | 121 | 122 | public function jsonSerialize(): string 123 | { 124 | return (string)$this; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Parsers/NvdFeedParser.php: -------------------------------------------------------------------------------- 1 | $cveIds 21 | * @return array 22 | * @throws ParseException 23 | */ 24 | public static function run(array $cveIds): array 25 | { 26 | ini_set('memory_limit', '2048M'); 27 | $feedNames = ['modified', 'recent']; 28 | $cvesById = array_flip($cveIds); 29 | $currentYear = (int) date('Y'); 30 | for ($cveYear = self::$CVE_START_YEAR; $cveYear <= $currentYear; $cveYear++) { 31 | $feedNames[] = (string)$cveYear; 32 | } 33 | 34 | $cveDetails = []; 35 | foreach ($feedNames as $feedName) { 36 | $cveDetails = array_merge($cveDetails, self::parseFeed($cvesById, $feedName)); 37 | } 38 | uksort( 39 | $cveDetails, 40 | fn (string $first, string $second): int => 41 | CveId::fromString($first)->compareTo(CveId::fromString($second)) 42 | ); 43 | return $cveDetails; 44 | } 45 | 46 | /** 47 | * @param array $cveIds 48 | * @return array 49 | * @throws ParseException 50 | */ 51 | private static function parseFeed(array $cveIds, string $feedName): array 52 | { 53 | Logger::info('Beginning NVD feed parse: ', $feedName); 54 | $cveDetails = []; 55 | $cveFeed = self::downloadFeed($feedName); 56 | 57 | $cveItems = $cveFeed->CVE_Items; 58 | $cveFeed = null; // free memory as fast as possible since this is very memory heavy 59 | foreach ($cveItems as $cveItem) { 60 | $cve = self::parseCveItem($cveItem); 61 | if ($cve && isset($cveIds[(string)$cve->getId()])) { 62 | $cveDetails[(string)$cve->getId()] = $cve; 63 | } 64 | } 65 | return $cveDetails; 66 | } 67 | 68 | /** 69 | * @throws ParseException 70 | */ 71 | private static function downloadFeed(string $feedName): \stdClass 72 | { 73 | try { 74 | return CachedDownload::json("https://nvd.nist.gov/feeds/json/cve/1.1/nvdcve-1.1-$feedName.json.gz"); 75 | } catch (DownloadException $ex) { 76 | if ($feedName === date('Y') && date('n') === '1') { 77 | Logger::warning('Unable to download feed ', $feedName, '. Skipping due to beginning of the year.'); 78 | return (object) [ 79 | 'CVE_Items' => [], 80 | ]; 81 | } 82 | throw ParseException::fromException($ex, __FILE__, __LINE__); 83 | } 84 | } 85 | 86 | private static function parseCveItem(\stdClass $cveItem): ?CveDetails 87 | { 88 | if (!isset($cveItem->cve->CVE_data_meta->ID)) { 89 | return null; 90 | } 91 | $id = CveId::fromString($cveItem->cve->CVE_data_meta->ID); 92 | if ($id === null) { 93 | return null; 94 | } 95 | $publishedDate = DateHelpers::fromCveFormatToISO8601($cveItem->publishedDate); 96 | $lastModifiedDate = DateHelpers::fromCveFormatToISO8601($cveItem->lastModifiedDate); 97 | $description = null; 98 | $baseScore = null; 99 | if (isset($cveItem->cve->description->description_data)) { 100 | foreach ($cveItem->cve->description->description_data as $description) { 101 | if ($description->lang === 'en') { 102 | $description = $description->value; 103 | break; 104 | } 105 | } 106 | } 107 | 108 | if (isset($cveItem->impact->baseMetricV3->cvssV3->baseScore)) { 109 | $baseScore = $cveItem->impact->baseMetricV3->cvssV3->baseScore; 110 | } elseif (isset($cveItem->impact->baseMetricV2->cvssV2->baseScore)) { 111 | $baseScore = $cveItem->impact->baseMetricV2->cvssV2->baseScore; 112 | } 113 | return new CveDetails($id, $baseScore, $publishedDate, $lastModifiedDate, $description); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /docs/fonts/inter.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter'; 3 | font-style: normal; 4 | font-weight: 100; 5 | font-display: swap; 6 | src: url("Inter-Thin.woff2?v=3.19") format("woff2"), 7 | url("Inter-Thin.woff?v=3.19") format("woff"); 8 | } 9 | @font-face { 10 | font-family: 'Inter'; 11 | font-style: italic; 12 | font-weight: 100; 13 | font-display: swap; 14 | src: url("Inter-ThinItalic.woff2?v=3.19") format("woff2"), 15 | url("Inter-ThinItalic.woff?v=3.19") format("woff"); 16 | } 17 | 18 | @font-face { 19 | font-family: 'Inter'; 20 | font-style: normal; 21 | font-weight: 200; 22 | font-display: swap; 23 | src: url("Inter-ExtraLight.woff2?v=3.19") format("woff2"), 24 | url("Inter-ExtraLight.woff?v=3.19") format("woff"); 25 | } 26 | @font-face { 27 | font-family: 'Inter'; 28 | font-style: italic; 29 | font-weight: 200; 30 | font-display: swap; 31 | src: url("Inter-ExtraLightItalic.woff2?v=3.19") format("woff2"), 32 | url("Inter-ExtraLightItalic.woff?v=3.19") format("woff"); 33 | } 34 | 35 | @font-face { 36 | font-family: 'Inter'; 37 | font-style: normal; 38 | font-weight: 300; 39 | font-display: swap; 40 | src: url("Inter-Light.woff2?v=3.19") format("woff2"), 41 | url("Inter-Light.woff?v=3.19") format("woff"); 42 | } 43 | @font-face { 44 | font-family: 'Inter'; 45 | font-style: italic; 46 | font-weight: 300; 47 | font-display: swap; 48 | src: url("Inter-LightItalic.woff2?v=3.19") format("woff2"), 49 | url("Inter-LightItalic.woff?v=3.19") format("woff"); 50 | } 51 | 52 | @font-face { 53 | font-family: 'Inter'; 54 | font-style: normal; 55 | font-weight: 400; 56 | font-display: swap; 57 | src: url("Inter-Regular.woff2?v=3.19") format("woff2"), 58 | url("Inter-Regular.woff?v=3.19") format("woff"); 59 | } 60 | @font-face { 61 | font-family: 'Inter'; 62 | font-style: italic; 63 | font-weight: 400; 64 | font-display: swap; 65 | src: url("Inter-Italic.woff2?v=3.19") format("woff2"), 66 | url("Inter-Italic.woff?v=3.19") format("woff"); 67 | } 68 | 69 | @font-face { 70 | font-family: 'Inter'; 71 | font-style: normal; 72 | font-weight: 500; 73 | font-display: swap; 74 | src: url("Inter-Medium.woff2?v=3.19") format("woff2"), 75 | url("Inter-Medium.woff?v=3.19") format("woff"); 76 | } 77 | @font-face { 78 | font-family: 'Inter'; 79 | font-style: italic; 80 | font-weight: 500; 81 | font-display: swap; 82 | src: url("Inter-MediumItalic.woff2?v=3.19") format("woff2"), 83 | url("Inter-MediumItalic.woff?v=3.19") format("woff"); 84 | } 85 | 86 | @font-face { 87 | font-family: 'Inter'; 88 | font-style: normal; 89 | font-weight: 600; 90 | font-display: swap; 91 | src: url("Inter-SemiBold.woff2?v=3.19") format("woff2"), 92 | url("Inter-SemiBold.woff?v=3.19") format("woff"); 93 | } 94 | @font-face { 95 | font-family: 'Inter'; 96 | font-style: italic; 97 | font-weight: 600; 98 | font-display: swap; 99 | src: url("Inter-SemiBoldItalic.woff2?v=3.19") format("woff2"), 100 | url("Inter-SemiBoldItalic.woff?v=3.19") format("woff"); 101 | } 102 | 103 | @font-face { 104 | font-family: 'Inter'; 105 | font-style: normal; 106 | font-weight: 700; 107 | font-display: swap; 108 | src: url("Inter-Bold.woff2?v=3.19") format("woff2"), 109 | url("Inter-Bold.woff?v=3.19") format("woff"); 110 | } 111 | @font-face { 112 | font-family: 'Inter'; 113 | font-style: italic; 114 | font-weight: 700; 115 | font-display: swap; 116 | src: url("Inter-BoldItalic.woff2?v=3.19") format("woff2"), 117 | url("Inter-BoldItalic.woff?v=3.19") format("woff"); 118 | } 119 | 120 | @font-face { 121 | font-family: 'Inter'; 122 | font-style: normal; 123 | font-weight: 800; 124 | font-display: swap; 125 | src: url("Inter-ExtraBold.woff2?v=3.19") format("woff2"), 126 | url("Inter-ExtraBold.woff?v=3.19") format("woff"); 127 | } 128 | @font-face { 129 | font-family: 'Inter'; 130 | font-style: italic; 131 | font-weight: 800; 132 | font-display: swap; 133 | src: url("Inter-ExtraBoldItalic.woff2?v=3.19") format("woff2"), 134 | url("Inter-ExtraBoldItalic.woff?v=3.19") format("woff"); 135 | } 136 | 137 | @font-face { 138 | font-family: 'Inter'; 139 | font-style: normal; 140 | font-weight: 900; 141 | font-display: swap; 142 | src: url("Inter-Black.woff2?v=3.19") format("woff2"), 143 | url("Inter-Black.woff?v=3.19") format("woff"); 144 | } 145 | @font-face { 146 | font-family: 'Inter'; 147 | font-style: italic; 148 | font-weight: 900; 149 | font-display: swap; 150 | src: url("Inter-BlackItalic.woff2?v=3.19") format("woff2"), 151 | url("Inter-BlackItalic.woff?v=3.19") format("woff"); 152 | } 153 | -------------------------------------------------------------------------------- /docs/stats.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Three Years and Running | PHP Version Audit 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |

31 | 32 | lightswitch05 avatar 33 | 34 | 35 | PHP Version Audit 36 | : Three Years and Running 37 |

38 | 39 |
40 |

41 | Quick Stats 42 |

    43 |
  • Updates: 2027
  • 44 |
  • CVEs: 34
  • 45 |
  • Releases: 102 46 |
      47 |
    • Major: 1
    • 48 |
    • Minor: 2
    • 49 |
    • Patch: 99
    • 50 |
    51 |
  • 52 |
  • Median hours: 5 hours (vs. 260 from CVE database - 98% faster)
  • 53 |
54 |

55 | 56 |

57 | A little over three years ago, I released the first version of PHP Version Audit. In case you've never 58 | heard of it before, it is just a simple utility to check a given version of PHP against known CVEs or 59 | support end dates. The coolest part of it (in my opinion) is that it self-updates by parsing the PHP 60 | changelog twice a day, discovering any new releases and CVEs that have been patched. What makes it stand 61 | out from other CVE tools is that the source being the Changelog means that the CVE alert is available long 62 | before the NVE CVE database has been updated with the information. Now that it has been up and running for 63 | three years, I thought it would be fun to look at some stats of the project. 64 |

65 | 66 |

67 | In the past three years, there have been 2,027 updates to the 68 | rules that 69 | drive PHP Version Audit. The vast majority of the updates being automatic on a cron schedule. Those 70 | automatic updates have parsed 34 CVEs from the changelog - across 71 | 102 version releases. PHP Version Audit has discovered CVE announcements on median 72 | of 5 hours after the Changelog update. The NVE CVE database gets updated with the CVEs on median of 73 | 260 hours - or almost 11 days after the Changelog update, making PHP Version Audit 98% faster than 74 | other tools that source from the CVE Database. I think that is pretty cool! 75 |

76 | 77 |

CVE Database update after php release announcement

78 | 79 |
80 | 81 |
82 | 83 |

PHP Version Audit update after php release announcement

84 | 85 |
86 | 87 |
88 |
89 | 90 |

91 | PHP Version Audit was designed from the beginning to be self-updating. For the most part, that design 92 | has worked out great. However, there are always some hiccups or breakages that require fixing. Below is a 93 | graph showing the update frequency over the lifespan of the project. The longest the self-updating feature 94 | was broken was 7 days. I've made it where PHP Version Audit will throw a 'Stale' exception if its last update 95 | is over 2 weeks, so I'm happy to say I'm well within the grace period. While things did a have bit of a rocky 96 | start, it is pretty rare to go un-updated for longer then 24 hours. 97 |

98 | 99 |

Update Frequency

100 |
101 | 102 |
103 | 104 |

105 | That is all for the stats! Thank you for reading. Earlier this year I released 106 | Node Version Audit, as it 107 | continues running it will be interesting to see how the stats compare with PHP in a few years. 108 | By the way, the only stat I don't know is if anyone 109 | is actually using this thing? 110 | I switched jobs recently and no longer use PHP, so while I enjoy keeping this project going, I no longer get to use it. 111 | It would be nice to know if someone out there is actually using it! 112 |

113 | 114 | 117 |
118 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/Rules.php: -------------------------------------------------------------------------------- 1 | lastUpdatedDate->getTimestamp(); 19 | if ($elapsedSeconds > 1_209_600) { 20 | throw StaleRulesException::fromString("Rules are older then two weeks"); 21 | } 22 | } 23 | 24 | /** 25 | * @throws Exceptions\DownloadException 26 | * @throws ParseException 27 | */ 28 | public static function loadRules(bool $noUpdate): \stdClass 29 | { 30 | $loadedRules = self::getRulesStdObject($noUpdate); 31 | return self::transformRules($loadedRules); 32 | } 33 | 34 | /** 35 | * @throws Exceptions\DownloadException 36 | * @throws ParseException 37 | */ 38 | private static function getRulesStdObject(bool $noUpdate): \stdClass 39 | { 40 | if (!$noUpdate) { 41 | try { 42 | return CachedDownload::json(self::$HOSTED_RULES_PATH); 43 | } catch (ParseException $ex) { 44 | Logger::warning($ex->getMessage()); 45 | } 46 | } 47 | 48 | // Either $noUpdate or download fresh rules failed - use package copy 49 | if (!is_file(__DIR__ . self::$RULES_PATH) || !$rulesString = file_get_contents(__DIR__ . self::$RULES_PATH)) { 50 | throw StaleRulesException::fromString("Unable to load rules from disk"); 51 | } 52 | try { 53 | return json_decode($rulesString, false, 512, JSON_THROW_ON_ERROR); 54 | } catch (\JsonException $e) { 55 | throw ParseException::fromException($e, __FILE__, __LINE__); 56 | } 57 | } 58 | 59 | private static function transformRules(\stdClass $rules): \stdClass 60 | { 61 | if (empty($rules->lastUpdatedDate) 62 | || empty($rules->latestVersions) 63 | || empty($rules->latestVersion) 64 | || empty($rules->supportEndDates)) { 65 | throw StaleRulesException::fromString("Unable to load rules"); 66 | } 67 | $rules->lastUpdatedDate = DateHelpers::fromISO8601($rules->lastUpdatedDate); 68 | foreach ($rules->latestVersions as $index => $latestVersion) { 69 | $rules->latestVersions->$index = PhpVersion::fromString($latestVersion); 70 | } 71 | $rules->latestVersion = PhpVersion::fromString($rules->latestVersion); 72 | foreach ($rules->supportEndDates as $index => $dates) { 73 | $rules->supportEndDates->$index->active = DateHelpers::fromISO8601($dates->active); 74 | $rules->supportEndDates->$index->security = DateHelpers::fromISO8601($dates->security); 75 | } 76 | foreach ($rules->releases as $versionString => $release) { 77 | $phpVersion = PhpVersion::fromString($versionString); 78 | $phpRelease = PhpRelease::fromReleaseDescription( 79 | $phpVersion, 80 | $release->releaseDate, 81 | json_encode($release->patchedCves, JSON_THROW_ON_ERROR) 82 | ); 83 | $rules->releases->$versionString = $phpRelease; 84 | } 85 | foreach ($rules->cves as $cveString => $cveDetails) { 86 | $rules->cves->$cveString = new CveDetails( 87 | CveId::fromString($cveDetails->id), 88 | (float)$cveDetails->baseScore, 89 | $cveDetails->publishedDate, 90 | $cveDetails->lastModifiedDate, 91 | $cveDetails->description 92 | ); 93 | } 94 | return $rules; 95 | } 96 | 97 | /** 98 | * @param array $releases 99 | * @param array $cves 100 | * @param array<\stdClass> $supportEndDates 101 | */ 102 | public static function saveRules(array $releases, array $cves, array $supportEndDates): void 103 | { 104 | $rules = (object) [ 105 | 'lastUpdatedDate' => DateHelpers::nowString(), 106 | 'name' => 'PHP Version Audit', 107 | 'website' => 'https://github.com/lightswitch05/php-version-audit', 108 | 'licence' => 'https://github.com/lightswitch05/php-version-audit/blob/master/LICENSE', 109 | 'source' => self::$HOSTED_RULES_PATH, 110 | 'releasesCount' => count($releases), 111 | 'cveCount' => count($cves), 112 | 'supportVersionsCount' => count(array_keys($supportEndDates)), 113 | 'latestVersion' => self::releasesToLatestVersion($releases), 114 | 'latestVersions' => self::releasesToLatestVersions($releases), 115 | 'supportEndDates' => $supportEndDates, 116 | 'releases' => self::formatReleases($releases), 117 | 'cves' => $cves, 118 | ]; 119 | self::writeRulesFile($rules); 120 | } 121 | 122 | private static function writeRulesFile(\stdClass $rules): void 123 | { 124 | file_put_contents(__DIR__ . self::$RULES_PATH, json_encode($rules, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 125 | } 126 | 127 | /** 128 | * @param PhpRelease[] $releases 129 | */ 130 | private static function releasesToLatestVersion(array $releases): ?PhpVersion 131 | { 132 | $latestVersion = null; 133 | foreach ($releases as $release) { 134 | $releaseVersion = $release->getVersion(); 135 | if ($releaseVersion->isPreRelease()) { 136 | continue; 137 | } 138 | $latestVersion ??= $releaseVersion; 139 | if ($releaseVersion->compareTo($latestVersion) > 0) { 140 | $latestVersion = $releaseVersion; 141 | } 142 | } 143 | return $latestVersion; 144 | } 145 | 146 | /** 147 | * @param PhpRelease[] $releases 148 | * @return array 149 | */ 150 | private static function releasesToLatestVersions(array $releases): array 151 | { 152 | $latestVersions = []; 153 | foreach ($releases as $release) { 154 | $version = $release->getVersion(); 155 | if ($version->isPreRelease()) { 156 | continue; 157 | } 158 | $major = $version->getMajor(); 159 | $minor = $version->getMinor(); 160 | $majorAndMinor = "$major.$minor"; 161 | if (!isset($latestVersions[$major])) { 162 | $latestVersions[$major] = $version; 163 | } 164 | if (!isset($latestVersions[$majorAndMinor])) { 165 | $latestVersions[$majorAndMinor] = $version; 166 | } 167 | if ($version->compareTo($latestVersions[$major]) > 0) { 168 | $latestVersions[$major] = $version; 169 | } 170 | if ($version->compareTo($latestVersions[$majorAndMinor]) > 0) { 171 | $latestVersions[$majorAndMinor] = $version; 172 | } 173 | } 174 | return $latestVersions; 175 | } 176 | 177 | /** 178 | * @param PhpRelease[] $releases 179 | * @return array 180 | */ 181 | private static function formatReleases(array $releases): array 182 | { 183 | $releasesByVersion = []; 184 | foreach ($releases as $release) { 185 | $releasesByVersion[(string)$release->getVersion()] = $release; 186 | } 187 | return $releasesByVersion; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /tests/unit/ApplicationTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($app->hasVulnerabilities()); 13 | } 14 | 15 | public function testItDoesNotHaveVulnerabilities() 16 | { 17 | $latestVersion = (new Application('7.4.1', true))->getLatestVersion(); 18 | $app = new Application($latestVersion, true); 19 | $this->assertFalse($app->hasVulnerabilities()); 20 | } 21 | 22 | public function testItRequiresAValidVersion() 23 | { 24 | $this->expectException(InvalidVersionException::class); 25 | new Application('7.4', true); 26 | } 27 | 28 | public function testItGetsLatestVersion() 29 | { 30 | $latestVersion = (new Application('7.4.0', true))->getLatestVersion(); 31 | $result = (new Application($latestVersion, true))->isLatestVersion(); 32 | $this->assertTrue($result); 33 | } 34 | 35 | public function testItIsNotLatestVersion() 36 | { 37 | $result = (new Application('7.3.0', true))->isLatestVersion(); 38 | $this->assertFalse($result); 39 | } 40 | 41 | public function testItGetsLatestPatchVersion() 42 | { 43 | $latestVersion = (new Application('7.3.0', true))->getLatestPatchVersion(); 44 | $result = (new Application($latestVersion, true))->isLatestPatchVersion(); 45 | $this->assertTrue($result); 46 | } 47 | 48 | public function testItIsNotLatestPatchVersion() 49 | { 50 | $result = (new Application('7.3.11', true))->isLatestVersion(); 51 | $this->assertFalse($result); 52 | } 53 | 54 | public function testItGetsLatestMinorVersion() 55 | { 56 | $latestVersion = (new Application('7.4.0', true))->getLatestMinorVersion(); 57 | $result = (new Application($latestVersion, true))->isLatestMinorVersion(); 58 | $this->assertTrue($result); 59 | } 60 | 61 | public function testItIsNotLatestMinorVersion() 62 | { 63 | $result = (new Application('7.3.12', true))->isLatestMinorVersion(); 64 | $this->assertFalse($result); 65 | } 66 | 67 | public function testUnknownMajorLatestMinorVersion() 68 | { 69 | $this->expectException(UnknownVersionException::class); 70 | (new Application('6.0.0', true))->getLatestMinorVersion(); 71 | } 72 | 73 | public function testUnknownMajorLatestPatchVersion() 74 | { 75 | $this->expectException(UnknownVersionException::class); 76 | (new Application('1.0.0', true))->getLatestpatchVersion(); 77 | } 78 | 79 | public function testUnknownMinorLatestPatchVersion() 80 | { 81 | $this->expectException(UnknownVersionException::class); 82 | (new Application('7.50.0', true))->getLatestPatchVersion(); 83 | } 84 | 85 | public function testUnknownPatchLatestPatchVersion() 86 | { 87 | $this->expectException(UnknownVersionException::class); 88 | (new Application('7.2.200', true))->getLatestPatchVersion(); 89 | } 90 | 91 | public function testGetSecurityEndDateValid() 92 | { 93 | $endDate = (new Application('7.4.0', true))->getSecuritySupportEndDate(); 94 | $this->assertNotEmpty($endDate); 95 | } 96 | 97 | public function testGetSecurityEndDateInvalid() 98 | { 99 | $this->expectException(UnknownVersionException::class); 100 | (new Application('6.1.0', true))->getSecuritySupportEndDate(); 101 | } 102 | 103 | public function testGetSecurityEndDateOld() 104 | { 105 | $endDate = (new Application('7.1.0', true))->getSecuritySupportEndDate(); 106 | $this->assertNotEmpty($endDate); 107 | } 108 | 109 | public function testGetSecurityEndDatePreRelease() 110 | { 111 | $endDate = (new Application('7.4.0rc4', true))->getSecuritySupportEndDate(); 112 | $this->assertNull($endDate); 113 | } 114 | 115 | public function testItHasSecuritySupportValid() 116 | { 117 | $hasSupport = (new Application('7.4.0', true))->hasSecuritySupport(); 118 | $this->assertTrue(is_bool($hasSupport)); 119 | } 120 | 121 | public function testItHasSecuritySupportOld() 122 | { 123 | $hasSupport = (new Application('7.0.0', true))->hasSecuritySupport(); 124 | $this->assertTrue(is_bool($hasSupport)); 125 | } 126 | 127 | public function testItHasSecuritySupportUnknown() 128 | { 129 | $this->expectException(UnknownVersionException::class); 130 | (new Application('6.2.0', true))->hasSecuritySupport(); 131 | } 132 | 133 | public function testItHasSecuritySupportPreRelease() 134 | { 135 | $hasSupport = (new Application('7.4.0rc3', true))->hasSecuritySupport(); 136 | $this->assertFalse($hasSupport); 137 | } 138 | 139 | public function testItHasActiveSupportValid() 140 | { 141 | $hasSupport = (new Application('7.4.0', true))->hasActiveSupport(); 142 | $this->assertTrue(is_bool($hasSupport)); 143 | } 144 | 145 | public function testItHasActiveSupportOld() 146 | { 147 | $hasSupport = (new Application('7.0.0', true))->hasActiveSupport(); 148 | $this->assertTrue(is_bool($hasSupport)); 149 | } 150 | 151 | public function testItHasActiveSupportUnknown() 152 | { 153 | $this->expectException(UnknownVersionException::class); 154 | (new Application('6.3.0', true))->hasActiveSupport(); 155 | } 156 | 157 | public function testItHasActiveSupportPreRelease() 158 | { 159 | $hasSupport = (new Application('7.4.0rc1', true))->hasActiveSupport(); 160 | $this->assertTrue(is_bool($hasSupport)); 161 | } 162 | 163 | public function testGetAllAuditDetailsValid() 164 | { 165 | $result = (new Application('7.4.0', true))->getAllAuditDetails(); 166 | $this->assertAllAuditDetails($result); 167 | } 168 | 169 | public function testGetAllAuditDetailsOld() 170 | { 171 | $result = (new Application('5.0.0', true))->getAllAuditDetails(); 172 | $this->assertAllAuditDetails($result); 173 | } 174 | 175 | public function testGetAllAuditDetailsPreRelease() 176 | { 177 | $result = (new Application('7.4.0rc1', true))->getAllAuditDetails(); 178 | $this->assertAllAuditDetails($result); 179 | } 180 | 181 | public function testGetAllAuditDetailsUnknown() 182 | { 183 | $this->expectException(UnknownVersionException::class); 184 | (new Application('6.4.0', true))->getAllAuditDetails(); 185 | } 186 | 187 | public function testGetRulesUpdateDate() 188 | { 189 | $date = (new Application('7.4.1', true))->getRulesLastUpdatedDate(); 190 | $this->assertNotEmpty($date); 191 | $this->assertIsString($date); 192 | } 193 | 194 | private function assertAllAuditDetails($result) 195 | { 196 | $this->assertNotEmpty($result); 197 | $this->assertTrue(is_string($result->auditVersion)); 198 | $this->assertNotEmpty($result->auditVersion); 199 | $this->assertTrue(is_bool($result->hasVulnerabilities)); 200 | $this->assertTrue(is_bool($result->hasVulnerabilities)); 201 | $this->assertTrue(is_bool($result->isLatestPatchVersion)); 202 | $this->assertTrue(is_bool($result->isLatestMinorVersion)); 203 | $this->assertTrue(is_bool($result->isLatestVersion)); 204 | $this->assertTrue(is_string($result->latestPatchVersion)); 205 | $this->assertNotEmpty($result->latestPatchVersion); 206 | $this->assertTrue(is_string($result->latestMinorVersion)); 207 | $this->assertNotEmpty($result->latestMinorVersion); 208 | $this->assertTrue(is_string($result->latestVersion)); 209 | $this->assertNotEmpty($result->latestVersion); 210 | $this->assertNotNull($result->vulnerabilities); 211 | $hasActiveSupportEndDate = property_exists($result, 'activeSupportEndDate'); 212 | $this->assertTrue($hasActiveSupportEndDate); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/Cli.php: -------------------------------------------------------------------------------- 1 | getMessage()); 43 | self::showHelp(); 44 | return self::$INVALID_ARG_CODE; 45 | } 46 | 47 | try { 48 | $app = new Application($args[self::$PHP_VERSION], $args[self::$NO_UPDATE]); 49 | } catch (InvalidVersionException $ex) { 50 | Logger::error($ex->getMessage()); 51 | return self::$INVALID_VERSION_CODE; 52 | } 53 | 54 | 55 | if ($args[self::$FULL_UPDATE]) { 56 | /** 57 | * PLEASE DO NOT USE THIS. This function is intended to only be used internally for updating 58 | * project rules in github, which can then be accessed by ALL instances of PHP Version Audit. 59 | * Running it locally puts unnecessary load on the source servers and cannot be re-used by others. 60 | * 61 | * The github hosted rules are setup on a cron schedule to update multiple times a day. 62 | * Running it directly will not provide you with any new information and will only 63 | * waste time and server resources. 64 | */ 65 | $app->fullRulesUpdate(); 66 | } 67 | 68 | try { 69 | $auditDetails = $app->getAllAuditDetails(); 70 | $output = json_encode($auditDetails, JSON_PRETTY_PRINT); 71 | fwrite(STDOUT, "$output" . PHP_EOL); 72 | 73 | if ($args[self::$FAIL_SECURITY] && ($auditDetails->hasVulnerabilities || !$auditDetails->hasSecuritySupport)) { 74 | return self::$FAIL_SECURITY_CODE; 75 | } 76 | if ($args[self::$FAIL_SUPPORT] && (!$auditDetails->hasSecuritySupport || !$auditDetails->hasActiveSupport)) { 77 | return self::$FAIL_SUPPORT_CODE; 78 | } 79 | if ($args[self::$FAIL_LATEST] && !$auditDetails->latestVersion) { 80 | return self::$FAIL_LATEST_CODE; 81 | } 82 | if ($args[self::$FAIL_PATCH] && !$auditDetails->isLatestPatchVersion) { 83 | return self::$FAIL_PATCH_CODE; 84 | } 85 | } catch (StaleRulesException $ex) { 86 | Logger::error($ex->getMessage()); 87 | return self::$FAIL_STALE_CODE; 88 | } 89 | 90 | return 0; 91 | } 92 | 93 | private static function getArgs(): array 94 | { 95 | $options = getopt('', [ 96 | self::$PHP_VERSION . '::', 97 | self::$HELP, 98 | self::$FAIL_SECURITY, 99 | self::$FAIL_LATEST, 100 | self::$FAIL_PATCH, 101 | self::$FAIL_SUPPORT, 102 | self::$NO_UPDATE, 103 | self::$FULL_UPDATE, 104 | 'silent', 105 | 'v', 106 | 'vv', 107 | 'vvv', 108 | ]); 109 | return [ 110 | self::$PHP_VERSION => self::getVersion($options), 111 | self::$HELP => self::getOptionalFlag($options, self::$HELP), 112 | self::$FULL_UPDATE => self::getOptionalFlag($options, self::$FULL_UPDATE), 113 | self::$NO_UPDATE => self::getOptionalFlag($options, self::$NO_UPDATE), 114 | self::$FAIL_SECURITY => self::getOptionalFlag($options, self::$FAIL_SECURITY), 115 | self::$FAIL_LATEST => self::getOptionalFlag($options, self::$FAIL_LATEST), 116 | self::$FAIL_PATCH => self::getOptionalFlag($options, self::$FAIL_PATCH), 117 | self::$FAIL_SUPPORT => self::getOptionalFlag($options, self::$FAIL_SUPPORT), 118 | 'verbosity' => self::getVerbosity($options), 119 | ]; 120 | } 121 | 122 | private static function getHelpArg(): bool 123 | { 124 | $options = getopt('', [ 125 | self::$HELP, 126 | ]); 127 | return self::getOptionalFlag($options, self::$HELP); 128 | } 129 | 130 | private static function showHelp(): void 131 | { 132 | $usageMask = "\t\t\t\t[--%s] [--%s]" . PHP_EOL; 133 | $argsMask = "--%s\t\t\t%s" . PHP_EOL; 134 | $argsErrorCodeMask = "--%s\t\t\tgenerate a %s %s" . PHP_EOL; 135 | printf("%s" . PHP_EOL, "PHP Version Audit"); 136 | printf("%s\t%s" . PHP_EOL, "usage: php-version-audit", "[--help] [--" . self::$PHP_VERSION . "=PHP_VERSION]"); 137 | printf($usageMask, self::$FAIL_SECURITY, self::$FAIL_SUPPORT); 138 | printf($usageMask, self::$FAIL_PATCH, self::$FAIL_LATEST); 139 | printf($usageMask, self::$NO_UPDATE, 'silent'); 140 | printf("\t\t\t\t[--%s]" . PHP_EOL, 'v'); 141 | printf("%s" . PHP_EOL, "optional arguments:"); 142 | printf($argsMask, self::$HELP, "\tshow this help message and exit."); 143 | printf($argsMask, self::$PHP_VERSION, "set the PHP Version to run against. Defaults to the runtime version. This is required when running with docker."); 144 | printf($argsErrorCodeMask, self::$FAIL_SECURITY, self::$FAIL_SECURITY_CODE, "exit code if any CVEs are found, or security support has ended."); 145 | printf($argsErrorCodeMask, self::$FAIL_SUPPORT, self::$FAIL_SUPPORT_CODE, "exit code if the version of PHP no longer gets active (bug) support."); 146 | printf($argsErrorCodeMask, self::$FAIL_PATCH, self::$FAIL_PATCH_CODE, "exit code if there is a newer patch-level release."); 147 | printf($argsErrorCodeMask, self::$FAIL_LATEST, self::$FAIL_LATEST_CODE, "exit code if there is a newer release."); 148 | printf($argsMask, self::$NO_UPDATE, "do not download the latest rules. NOT RECOMMENDED!"); 149 | printf($argsMask, 'silent', "do not write any error messages to STDERR."); 150 | printf($argsMask, 'v', "\tSet verbosity. v=warnings, vv=info, vvv=debug. Default is error. All logging writes to STDERR."); 151 | } 152 | 153 | private static function getVersion(array $options): string 154 | { 155 | if (isset($options[self::$PHP_VERSION]) && !empty($options[self::$PHP_VERSION])) { 156 | return $options[self::$PHP_VERSION]; 157 | } 158 | if (getenv('REQUIRE_VERSION_ARG', true) === 'true') { 159 | throw new InvalidArgumentException("Missing required argument: --version"); 160 | } 161 | return phpversion(); 162 | } 163 | 164 | private static function getOptionalFlag(array $options, string $name): bool 165 | { 166 | return isset($options[$name]); 167 | } 168 | 169 | private static function getVerbosity(array $options): int 170 | { 171 | if (self::getOptionalFlag($options, 'vvv')) { 172 | return Logger::DEBUG; 173 | } 174 | if (self::getOptionalFlag($options, 'vv')) { 175 | return Logger::INFO; 176 | } 177 | if (self::getOptionalFlag($options, 'v')) { 178 | return Logger::WARNING; 179 | } 180 | if (self::getOptionalFlag($options, 'silent')) { 181 | return Logger::SILENT; 182 | } 183 | return Logger::ERROR; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/CachedDownload.php: -------------------------------------------------------------------------------- 1 | true, 17 | CURLOPT_ACCEPT_ENCODING => '', 18 | CURLOPT_RETURNTRANSFER => true, 19 | CURLOPT_USERAGENT => 'php-version-audit', 20 | ]; 21 | 22 | /** 23 | * @throws ParseException 24 | * @throws DownloadException 25 | */ 26 | public static function download(string $url): string 27 | { 28 | self::setup(); 29 | try { 30 | return self::downloadCachedFile($url); 31 | } catch (\JsonException $e) { 32 | throw ParseException::fromException($e, __FILE__, __LINE__); 33 | } 34 | } 35 | 36 | /** 37 | * @throws ParseException 38 | * @throws DownloadException 39 | */ 40 | public static function dom(string $url): \DOMDocument 41 | { 42 | $html = self::download($url); 43 | $doc = new DOMDocument(); 44 | $dom = $doc->loadHTML($html, LIBXML_NOWARNING | LIBXML_NONET | LIBXML_NOERROR); 45 | if ($dom === false) { 46 | throw ParseException::fromString("Unable to parse url: " . $url); 47 | } 48 | return $doc; 49 | } 50 | 51 | /** 52 | * @throws ParseException 53 | * @throws DownloadException 54 | */ 55 | public static function json(string $url): \stdClass 56 | { 57 | $html = self::download($url); 58 | try { 59 | return json_decode($html, false, 512, JSON_THROW_ON_ERROR); 60 | } catch (\JsonException $e) { 61 | throw ParseException::fromException($e, __FILE__, __LINE__); 62 | } 63 | } 64 | 65 | /** 66 | * @throws ParseException 67 | * @throws DownloadException 68 | * @throws \JsonException 69 | */ 70 | private static function downloadCachedFile(string $url): string 71 | { 72 | if (self::isCached($url)) { 73 | return self::getFileFromCache($url); 74 | } 75 | $modifiedDate = self::getServerLastModifiedDate($url); 76 | if (str_ends_with($url, 'gz')) { 77 | $data = self::downloadGZipFile($url); 78 | } else { 79 | $data = self::downloadFile($url); 80 | } 81 | self::writeCacheFile($url, $data, $modifiedDate); 82 | return $data; 83 | } 84 | 85 | /** 86 | * @throws DownloadException 87 | * @throws ParseException 88 | */ 89 | private static function downloadGZipFile(string $url): string 90 | { 91 | $encoded = self::downloadFile($url); 92 | $data = gzdecode($encoded); 93 | if ($data === false) { 94 | throw ParseException::fromString("Unable to parse file: $url"); 95 | } 96 | return $data; 97 | } 98 | 99 | /** 100 | * @throws DownloadException 101 | * 102 | * @psalm-suppress InvalidReturnType 103 | * @psalm-suppress InvalidReturnStatement 104 | */ 105 | private static function downloadFile(string $url, int $attempt = 0): string 106 | { 107 | Logger::debug('Downloading attempt ', $attempt, ': ', $url); 108 | $ch = curl_init($url); 109 | curl_setopt_array($ch, self::DEFAULT_CURL_OPTS); 110 | $response = curl_exec($ch); 111 | curl_close($ch); 112 | if ($response !== false) { 113 | return $response; 114 | } 115 | 116 | if ($attempt < self::MAX_RETRY) { 117 | sleep(15); 118 | return self::downloadFile($url, $attempt + 1); 119 | } 120 | throw DownloadException::fromString("Unable to download: $url"); 121 | } 122 | 123 | /** 124 | * @throws ParseException 125 | */ 126 | private static function getFileFromCache(string $url): string 127 | { 128 | Logger::debug('Loading file from cache: ', $url); 129 | $filename = self::urlToFileName($url); 130 | $fullPath = self::getCachePath($filename); 131 | if (!is_file($fullPath)) { 132 | throw ParseException::fromString("Cached file not found: $fullPath"); 133 | } 134 | $contents = file_get_contents($fullPath); 135 | if ($contents === false) { 136 | throw ParseException::fromString("Unable to read cached file: $fullPath"); 137 | } 138 | return $contents; 139 | } 140 | 141 | /** 142 | * @throws \JsonException 143 | */ 144 | private static function isCached(string $url): bool 145 | { 146 | $cacheIndex = self::getCacheIndex(); 147 | if (!isset($cacheIndex->$url)) { 148 | Logger::debug('Cache does not exist for ', $url); 149 | return false; 150 | } 151 | $lastModifiedDate = DateHelpers::fromISO8601($cacheIndex->$url->lastModifiedDate); 152 | $expired = self::isExpired($url, $lastModifiedDate); 153 | if ($expired) { 154 | Logger::debug('Cache has expired for ', $url); 155 | } else { 156 | Logger::debug('Cache is valid for ', $url); 157 | } 158 | return !$expired; 159 | } 160 | 161 | private static function isExpired(string $url, \DateTimeImmutable $lastModifiedDate): bool 162 | { 163 | $lastModifiedTimestamp = $lastModifiedDate->getTimestamp(); 164 | $elapsedSeconds = DateHelpers::nowTimestamp() - $lastModifiedTimestamp; 165 | // enforce a minimum cache of 1 hour to makeup for lack of last modified time on changelog 166 | if ($elapsedSeconds < 3600) { 167 | Logger::debug('Cache time under 3600: ', $url); 168 | return false; 169 | } 170 | $serverLastModifiedDate = self::getServerLastModifiedDate($url); 171 | return $serverLastModifiedDate->getTimestamp() > $lastModifiedTimestamp; 172 | } 173 | 174 | private static function getServerLastModifiedDate(string $url, int $attempt = 0): \DateTimeImmutable 175 | { 176 | $ch = curl_init($url); 177 | curl_setopt_array($ch, self::DEFAULT_CURL_OPTS); 178 | curl_setopt($ch, CURLOPT_NOBODY, true); 179 | curl_setopt($ch, CURLOPT_FILETIME, true); 180 | $response = curl_exec($ch); 181 | $fileTime = curl_getinfo($ch, CURLINFO_FILETIME); 182 | curl_close($ch); 183 | if ($response !== false && $fileTime > -1) { 184 | return DateHelpers::fromTimestamp($fileTime); 185 | } 186 | 187 | if ($response === false && $attempt < self::MAX_RETRY) { 188 | sleep(15); 189 | return self::getServerLastModifiedDate($url, $attempt + 1); 190 | } 191 | 192 | // Fall back on assuming it was just updated 193 | return new \DateTimeImmutable(); 194 | } 195 | 196 | private static function urlToFileName(string $url): string 197 | { 198 | $hash = hash("sha256", $url); 199 | return substr($hash, 0, 15) . ".txt"; 200 | } 201 | 202 | private static function setup(): void 203 | { 204 | $tempDir = self::getCachePath(); 205 | if (!is_dir($tempDir)) { 206 | mkdir($tempDir); 207 | } 208 | $indexPath = self::getCachePath(self::INDEX_FILE_NAME); 209 | if (!is_file($indexPath)) { 210 | Logger::debug('Cache index not found, creating new one.'); 211 | self::saveCacheIndex(new \stdClass()); 212 | } 213 | } 214 | 215 | /** 216 | * @throws \JsonException 217 | */ 218 | private static function writeCacheFile(string $url, string $data, \DateTimeImmutable $modifiedDate): void 219 | { 220 | $cacheIndex = self::getCacheIndex(); 221 | $filename = self::urlToFileName($url); 222 | $cacheIndex->$url = new \stdClass(); 223 | $cacheIndex->$url->filename = $filename; 224 | $cacheIndex->$url->lastModifiedDate = DateHelpers::toISO8601($modifiedDate); 225 | file_put_contents(self::getCachePath($filename), $data); 226 | self::saveCacheIndex($cacheIndex); 227 | } 228 | 229 | /** 230 | * @throws \JsonException 231 | */ 232 | private static function getCacheIndex(): \stdClass 233 | { 234 | $fullPath = self::getCachePath(self::INDEX_FILE_NAME); 235 | $index = file_get_contents($fullPath); 236 | return json_decode($index, false, 513, JSON_THROW_ON_ERROR); 237 | } 238 | 239 | private static function saveCacheIndex(\stdClass $index): void 240 | { 241 | $fullPath = self::getCachePath(self::INDEX_FILE_NAME); 242 | $data = json_encode($index, JSON_PRETTY_PRINT); 243 | file_put_contents($fullPath, $data); 244 | } 245 | 246 | private static function getCachePath(?string $filename = null): string 247 | { 248 | return __DIR__ . "/../tmp/$filename"; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | auditVersion = PhpVersion::fromString($phpVersion); 26 | if ($this->auditVersion === null) { 27 | throw InvalidVersionException::fromString($phpVersion); 28 | } 29 | $this->rules = Rules::loadRules($noUpdate); 30 | } 31 | 32 | public function getVulnerabilities(): \stdClass 33 | { 34 | $cves = []; 35 | $majorAndMinor = $this->auditVersion->getMajorMinorVersionString(); 36 | $maxVersion = PhpVersion::fromString($majorAndMinor . ".9999"); 37 | foreach ($this->rules->releases as $versionString => $release) { 38 | $releaseVersion = PhpVersion::fromString($versionString); 39 | if ($releaseVersion->compareTo($this->auditVersion) <= 0 || 40 | $releaseVersion->compareTo($maxVersion) > 0) { 41 | continue; 42 | } 43 | $cves = array_merge($cves, $release->getPatchedCveIds()); 44 | } 45 | $vulnerabilities = new \stdClass(); 46 | foreach ($cves as $cve) { 47 | $cveDetails = null; 48 | $cveString = (string)$cve->getId(); 49 | if (isset($this->rules->cves->$cveString)) { 50 | $cveDetails = $this->rules->cves->$cveString; 51 | } 52 | $vulnerabilities->$cveString = $cveDetails; 53 | } 54 | return $vulnerabilities; 55 | } 56 | 57 | public function hasVulnerabilities(): bool 58 | { 59 | return !empty((array) $this->getVulnerabilities()); 60 | } 61 | 62 | public function isLatestVersion(): bool 63 | { 64 | $versionString = self::getLatestVersion(); 65 | $latestVersion = PhpVersion::fromString($versionString); 66 | return $this->auditVersion->compareTo($latestVersion) === 0; 67 | } 68 | 69 | public function getLatestVersion(): string 70 | { 71 | if (!$this->rules->latestVersion) { 72 | throw StaleRulesException::fromString("Latest PHP version is unknown!"); 73 | } 74 | return (string) $this->rules->latestVersion; 75 | } 76 | 77 | public function isLatestPatchVersion(): bool 78 | { 79 | $versionString = self::getLatestPatchVersion(); 80 | $latestVersion = PhpVersion::fromString($versionString); 81 | return $this->auditVersion->compareTo($latestVersion) === 0; 82 | } 83 | 84 | public function getLatestPatchVersion(): string 85 | { 86 | $majorAndMinor = $this->auditVersion->getMajorMinorVersionString(); 87 | if (!isset($this->rules->latestVersions->$majorAndMinor)) { 88 | throw UnknownVersionException::fromString((string)$this->auditVersion); 89 | } 90 | $latestPatch = $this->rules->latestVersions->$majorAndMinor; 91 | if ($this->auditVersion->compareTo($latestPatch) > 0) { 92 | throw UnknownVersionException::fromString((string)$this->auditVersion); 93 | } 94 | return (string) $latestPatch; 95 | } 96 | 97 | public function isLatestMinorVersion(): bool 98 | { 99 | $versionString = self::getLatestMinorVersion(); 100 | $latestVersion = PhpVersion::fromString($versionString); 101 | return $this->auditVersion->compareTo($latestVersion) === 0; 102 | } 103 | 104 | public function getLatestMinorVersion(): string 105 | { 106 | $major = (string) $this->auditVersion->getMajor(); 107 | if (!isset($this->rules->latestVersions->$major)) { 108 | throw UnknownVersionException::fromString((string)$this->auditVersion); 109 | } 110 | return (string) $this->rules->latestVersions->$major; 111 | } 112 | 113 | public function getSecuritySupportEndDate(): ?string 114 | { 115 | return $this->getSupportEndDate('security'); 116 | } 117 | 118 | public function hasSecuritySupport(): bool 119 | { 120 | $endDateString = self::getSecuritySupportEndDate(); 121 | if (!$endDateString) { 122 | return false; 123 | } 124 | $endDate = DateHelpers::fromISO8601($endDateString); 125 | return DateHelpers::nowTimestamp() - $endDate->getTimestamp() < 0; 126 | } 127 | 128 | public function getActiveSupportEndDate(): ?string 129 | { 130 | return $this->getSupportEndDate('active'); 131 | } 132 | 133 | private function getSupportEndDate(string $supportType): ?string 134 | { 135 | if ($this->auditVersion->isPreRelease()) { 136 | return null; 137 | } 138 | $majorAndMinor = $this->auditVersion->getMajorMinorVersionString(); 139 | if (!isset($this->rules->supportEndDates->$majorAndMinor)) { 140 | throw UnknownVersionException::fromString((string)$this->auditVersion); 141 | } 142 | return DateHelpers::toISO8601($this->rules->supportEndDates->$majorAndMinor->$supportType); 143 | } 144 | 145 | public function hasActiveSupport(): bool 146 | { 147 | $endDateString = self::getActiveSupportEndDate(); 148 | if (!$endDateString) { 149 | return false; 150 | } 151 | $endDate = DateHelpers::fromISO8601($endDateString); 152 | return DateHelpers::nowTimestamp() - $endDate->getTimestamp() < 0; 153 | } 154 | 155 | public function getRulesLastUpdatedDate(): ?string 156 | { 157 | return DateHelpers::toISO8601($this->rules->lastUpdatedDate); 158 | } 159 | 160 | public function getAllAuditDetails(): \stdClass 161 | { 162 | Rules::assertFreshRules($this->rules); 163 | return (object) [ 164 | 'auditVersion' => (string) $this->auditVersion, 165 | 'hasVulnerabilities' => $this->hasVulnerabilities(), 166 | 'hasSecuritySupport' => $this->hasSecuritySupport(), 167 | 'hasActiveSupport' => $this->hasActiveSupport(), 168 | 'isLatestPatchVersion' => $this->isLatestPatchVersion(), 169 | 'isLatestMinorVersion' => $this->isLatestMinorVersion(), 170 | 'isLatestVersion' => $this->isLatestVersion(), 171 | 'latestPatchVersion' => $this->getLatestPatchVersion(), 172 | 'latestMinorVersion' => $this->getLatestMinorVersion(), 173 | 'latestVersion' => $this->getLatestVersion(), 174 | 'activeSupportEndDate' => $this->getActiveSupportEndDate(), 175 | 'securitySupportEndDate' => $this->getSecuritySupportEndDate(), 176 | 'rulesLastUpdatedDate' => $this->getRulesLastUpdatedDate(), 177 | 'vulnerabilities' => $this->getVulnerabilities(), 178 | ]; 179 | } 180 | 181 | /** 182 | * @note PLEASE DO NOT USE THIS. This function is intended to only be used internally for updating 183 | * project rules in github, which can then be accessed by ALL instances of PHP Version Audit. 184 | * Running it locally puts unnecessary load on the source servers and cannot be re-used by others. 185 | * 186 | * The github hosted rules are setup on a cron schedule to update multiple times a day. 187 | * Running it directly will not provide you with any new information and will only 188 | * waste time and server resources. 189 | */ 190 | public function fullRulesUpdate(): void 191 | { 192 | Logger::warning('Running full rules update, this is slow and should not be ran locally!'); 193 | $releases = ChangelogParser::run(); 194 | $cves = $this->loadCveDetails($releases); 195 | $supportEndDates = SupportParser::run(); 196 | $this->assertExpectedRulesCount($releases, $cves, $supportEndDates); 197 | 198 | Rules::saveRules($releases, $cves, $supportEndDates); 199 | $this->rules = Rules::loadRules(true); 200 | } 201 | 202 | /** 203 | * @param PhpRelease[] $releases 204 | * @return CveDetails[] 205 | */ 206 | private function loadCveDetails(array $releases): array 207 | { 208 | $cves = []; 209 | foreach ($releases as $release) { 210 | $patchedCveIds = $release->getPatchedCveIds(); 211 | foreach ($patchedCveIds as $cveId) { 212 | $cves[] = $cveId->getId(); 213 | } 214 | } 215 | return NvdFeedParser::run($cves); 216 | } 217 | 218 | /** 219 | * @param array $releases 220 | * @param array $cves 221 | * @param array<\stdClass> $supportEndDates 222 | */ 223 | private function assertExpectedRulesCount(array $releases, array $cves, array $supportEndDates): void 224 | { 225 | $currentCounts = [ 226 | 'releasesCount' => $this->rules->releasesCount, 227 | 'cveCount' => $this->rules->cveCount, 228 | 'supportVersionsCount' => $this->rules->supportVersionsCount, 229 | ]; 230 | 231 | $newCounts = [ 232 | 'releasesCount' => count($releases), 233 | 'cveCount' => count($cves), 234 | 'supportVersionsCount' => count(array_keys($supportEndDates)), 235 | ]; 236 | 237 | foreach ($currentCounts as $type => $currentCount) { 238 | if ($currentCount > $newCounts[$type]) { 239 | $error = "Updated rules failed to meet expected counts. {$type}: {$newCounts[$type]} < {$currentCount}"; 240 | throw StaleRulesException::fromString($error); 241 | } 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /docs/php-version-audit-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Version Audit 2 | 3 | [![PHP Version Audit Logo](https://www.github.developerdan.com/php-version-audit/php-version-audit-logo.svg)](https://www.github.developerdan.com/php-version-audit/) 4 | 5 | [![Github Stars](https://img.shields.io/github/stars/lightswitch05/php-version-audit)](https://github.com/lightswitch05/php-version-audit) 6 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/lightswitch05/php-version-audit/auto-updates.yml)](https://github.com/lightswitch05/php-version-audit/actions/workflows/auto-updates.yml?query=workflow%3A%22Auto+Updates%22) 7 | [![Packagist Version](https://img.shields.io/packagist/v/lightswitch05/php-version-audit)](https://packagist.org/packages/lightswitch05/php-version-audit) 8 | [![Docker Pulls](https://img.shields.io/docker/pulls/lightswitch05/php-version-audit)](https://hub.docker.com/r/lightswitch05/php-version-audit) 9 | [![license](https://img.shields.io/github/license/lightswitch05/php-version-audit.svg)](https://github.com/lightswitch05/php-version-audit/blob/master/LICENSE) 10 | [![last commit](https://img.shields.io/github/last-commit/lightswitch05/php-version-audit.svg)](https://github.com/lightswitch05/php-version-audit/commits/master) 11 | [![commit activity](https://img.shields.io/github/commit-activity/y/lightswitch05/php-version-audit.svg)](https://github.com/lightswitch05/php-version-audit/commits/master) 12 | 13 | PHP Version Audit is a convenience tool to easily check a given PHP version against a regularly updated 14 | list of CVE exploits, new releases, and end of life dates. 15 | 16 | **PHP Version Audit is not:** exploit detection/mitigation, vendor-specific version tracking, a replacement for 17 | staying informed on PHP releases and security exploits. 18 | 19 | 20 | > * [Features](#features) 21 | > * [Example](#example) 22 | > * [Usage](#usage) 23 | > * [Docker](#docker) 24 | > * [CLI](#cli) 25 | > * [Direct Invocation](#direct-invocation) 26 | > * [JSON Rules](#json-rules) 27 | > * [Options](#options) 28 | > * [Output](#output) 29 | > * [Project Goals](#project-goals) 30 | > * [Acknowledgments & License](#acknowledgments--license) 31 | 32 | ## Features: 33 | * List known CVEs for a given version of PHP 34 | * Check either the runtime version of PHP, or a supplied version 35 | * Display end-of-life dates for a given version of PHP 36 | * Display new releases for a given version of PHP with configurable specificity (latest/minor/patch) 37 | * Patch: 7.3.0 -> 7.3.33 38 | * Minor: 7.3.0 -> 7.4.27 39 | * Latest: 7.3.0 -> 8.1.1 40 | * Rules automatically updated twice a day. Information is sourced directly from php.net - you'll never be waiting on someone like me to merge a pull request before getting the latest patch information. 41 | * Multiple interfaces: CLI (via PHP Composer), Docker, direct code import 42 | * Easily scriptable for use with CI/CD workflows. All Docker/CLI outputs are in JSON format to be consumed with your favorite tools - such as [jq](https://stedolan.github.io/jq/). 43 | * Configurable exit conditions. Use CLI flags like `--fail-security` to set a failure exit code if the given version of PHP has a known CVE or is no longer receiving security updates. 44 | * Zero dependencies 45 | 46 | ## Example: 47 | docker run --rm -t lightswitch05/php-version-audit:latest --version=8.0.12 48 | { 49 | "auditVersion": "8.0.12", 50 | "hasVulnerabilities": true, 51 | "hasSecuritySupport": true, 52 | "hasActiveSupport": true, 53 | "isLatestPatchVersion": false, 54 | "isLatestMinorVersion": false, 55 | "isLatestVersion": false, 56 | "latestPatchVersion": "8.0.14", 57 | "latestMinorVersion": "8.1.1", 58 | "latestVersion": "8.1.1", 59 | "activeSupportEndDate": "2022-11-26T00:00:00+0000", 60 | "securitySupportEndDate": "2023-11-26T00:00:00+0000", 61 | "rulesLastUpdatedDate": "2022-01-18T02:13:52+0000", 62 | "vulnerabilities": { 63 | "CVE-2021-21707": { 64 | "id": "CVE-2021-21707", 65 | "baseScore": 5.3, 66 | "publishedDate": "2021-11-29T07:15:00+0000", 67 | "lastModifiedDate": "2022-01-04T16:12:00+0000", 68 | "description": "In PHP versions 7.3.x below 7.3.33, 7.4.x below 7.4.26 and 8.0.x below 8.0.13, certain XML parsing functions, like simplexml_load_file(), URL-decode the filename passed to them. If that filename contains URL-encoded NUL character, this may cause the function to interpret this as the end of the filename, thus interpreting the filename differently from what the user intended, which may lead it to reading a different file than intended." 69 | } 70 | } 71 | } 72 | 73 | ## Usage 74 | 75 | ### Docker 76 | 77 | Running with docker is the preferred and easiest way to use PHP Version Audit. 78 | 79 | Check a specific version of PHP using Docker: 80 | 81 | docker run --rm -t lightswitch05/php-version-audit:latest --version=8.1.1 82 | 83 | Check the host's PHP version using Docker: 84 | 85 | docker run --rm -t lightswitch05/php-version-audit:latest --version=$(php -r 'echo phpversion();') 86 | 87 | Run behind an HTTPS proxy (for use on restricted networks). Requires a volume mount of a directory with your trusted cert (with .crt extension) - see [update-ca-certificates](https://manpages.debian.org/buster/ca-certificates/update-ca-certificates.8.en.html) for more details. 88 | 89 | docker run --rm -t -e https_proxy='https://your.proxy.server:port/' --volume /full/path/to/trusted/certs/directory:/usr/local/share/ca-certificates lightswitch05/php-version-audit:latest --version=8.1.1 90 | 91 | ### CLI 92 | 93 | Not using docker? Not a problem. It is a couple more steps, but it is just as easy to run directly. 94 | 95 | Install the package via composer: 96 | 97 | composer require lightswitch05/php-version-audit:~1.0 98 | 99 | Execute the PHP script, checking the run-time version of PHP: 100 | 101 | ./vendor/bin/php-version-audit 102 | 103 | Produce an exit code if any CVEs are found: 104 | 105 | ./vendor/bin/php-version-audit --fail-security 106 | 107 | ### Direct Invocation 108 | 109 | Want to integrate with PHP Version Audit? That's certainly possible. A word caution, this is a very early release. I do not have any plans for breaking changes, but I'm also not committed to keeping the interface as-is if there are new features to implement. Docker/CLI is certainly the preferred method over direct invocation. 110 | 111 | $phpVersionAudit = new lightswitch05\PhpVersionAudit\Application(phpversion(), false); 112 | $phpVersionAudit->hasVulnerabilities(); #=> true 113 | $phpVersionAudit->getLatestPatchVersion(); #=> '8.1.1' 114 | 115 | ### JSON Rules 116 | 117 | The data used to drive PHP Version Audit is automatically updated on a regular basis and is hosted on GitHub pages. This is the real meat-and-potatoes of PHP Version Audit, and you can consume it directly for use in other tools. If you choose to do this, please respect the project license by giving proper attribution notices. Also, I ask any implementations to read the `lastUpdatedDate` and fail if it has become out of date (2+ weeks). This should not happen since it is automatically updated... but we all know how fragile software is. 118 | 119 | Get the latest PHP 8.1 release version directly from the rules using [curl](https://curl.haxx.se/) and [jq](https://stedolan.github.io/jq/): 120 | 121 | curl -s https://www.github.developerdan.com/php-version-audit/rules-v1.json | jq '.latestVersions["8.1"]' 122 | 123 | ### Options 124 | 125 | usage: php-version-audit [--help] [--version=PHP_VERSION] 126 | [--fail-security] [--fail-support] 127 | [--fail-patch] [--fail-latest] 128 | [--no-update] [--silent] 129 | [--v] 130 | 131 | optional arguments: 132 | --help show this help message and exit. 133 | --version set the PHP Version to run against. Defaults to the runtime version. This is required when running with docker. 134 | --fail-security generate a 10 exit code if any CVEs are found, or security support has ended. 135 | --fail-support generate a 20 exit code if the version of PHP no longer gets active (bug) support. 136 | --fail-patch generate a 30 exit code if there is a newer patch-level release. 137 | --fail-latest generate a 40 exit code if there is a newer release. 138 | --no-update do not download the latest rules. NOT RECOMMENDED! 139 | --silent do not write any error messages to STDERR. 140 | --v Set verbosity. v=warnings, vv=info, vvv=debug. Default is error. All logging writes to STDERR. 141 | 142 | ### Output 143 | 144 | * auditVersion: string - The version of PHP that is being audited. 145 | * hasVulnerabilities: bool - If the auditVersion has any known CVEs or not. 146 | * hasSecuritySupport: bool - If the auditVersion is still receiving security updates. 147 | * hasActiveSupport: bool - If the auditVersion is still receiving active support (bug updates). 148 | * isLatestPatchVersion: bool - If auditVersion is the latest patch-level release (8.0.x). 149 | * isLatestMinorVersion: bool - If auditVersion is the latest minor-level release (8.x.x). 150 | * isLatestVersion: bool - If auditVersion is the latest release (x.x.x). 151 | * latestPatchVersion: string - The latest patch-level version for auditVersion. 152 | * latestMinorVersion: string - The latest minor-level version for auditVersion. 153 | * latestVersion: string - The latest PHP version. 154 | * activeSupportEndDate: string|null - ISO8601 formatted date for the end of active support for auditVersion (bug fixes). 155 | * securitySupportEndDate: string - ISO8601 formatted date for the end of security support for auditVersion. 156 | * rulesLastUpdatedDate: string - ISO8601 formatted date for the last time the rules were auto-updated (twice a day).. 157 | * vulnerabilities: object - CVEs known to affect auditVersion with details about the CVE. CVE Details might be null for recently discovered CVEs. 158 | 159 | ## Project Goals: 160 | 161 | * Always use update-to-date information and fail if it becomes too stale. Since this tool is designed to help its users stay informed, it must in turn fail if it becomes outdated. 162 | * Fail if the requested information is unavailable. ex. getting the support end date of PHP version 6.0, or 5.7.0. Again, since this tool is designed to help its users stay informed, it must in turn fail if the requested information is unavailable. 163 | * Work in both open and closed networks (as long as the tool is up-to-date). 164 | * Minimal footprint and dependencies. 165 | * Runtime support for the oldest supported version of PHP. If you are using this tool with an unsupported version of PHP, then you already have all the answers that this tool can give you: Yes, you have vulnerabilities and are out of date. Of course that is just for the run-time, it is still the goal of this project to supply information about any reasonable version of PHP. 166 | 167 | ## Acknowledgments & License 168 | 169 | * This project is released under the [Apache License 2.0](https://raw.githubusercontent.com/lightswitch05/php-version-audit/master/LICENSE). 170 | * The accuracy of the information provided by this project cannot be verified or guaranteed. All functions are provided as convenience only and should not be used for reliability, accuracy, or punctuality. 171 | * The logo was created using Colin Viebrock's [PHP Logo](https://www.php.net/download-logos.php) as the base image, released under [Creative Commons Attribution-Share Alike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/). The logo has been modified from its original form to include overlay graphics. 172 | * This project and the use of the modified PHP logo is not endorsed by Colin Viebrock. 173 | * This project and the use of the PHP name is not endorsed by The PHP Group. 174 | * CVE details and descriptions are downloaded from National Institute of Standard and Technology's [National Vulnerability Database](https://nvd.nist.gov/). This project and the use of CVE information is not endorsed by NIST or the NVD. CVE details are provided as convenience only. The accuracy of the information cannot be verified. 175 | * PHP release details and support dates are parsed from ChangeLogs ([4](https://www.php.net/ChangeLog-4.php), [5](https://www.php.net/ChangeLog-5.php), [7](https://www.php.net/ChangeLog-7.php), [8](https://www.php.net/ChangeLog-8.php)) as well as [Supported Versions](https://www.php.net/supported-versions.php) and [EOL dates](https://www.php.net/eol.php). The accuracy of the information cannot be verified. 176 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PHP Version Audit 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |

31 | 32 | lightswitch05 avatar 33 | 34 | PHP Version Audit: Source on Github 35 |

36 | 37 |

38 | 39 | Github Stars 40 | 41 | 42 | GitHub Workflow Status 43 | 44 | 45 | Packagist Version 46 | 47 | 48 | Docker Pulls 49 | 50 | 51 | license 52 | 53 | 54 | last commit 55 | 56 | 57 | commit activity 58 | 59 |

60 |

61 | PHP Version Audit is a convenience tool to easily check a given PHP version against a regularly updated 62 | list of CVE exploits, new releases, and end of life dates. 63 |

64 |

65 | PHP Version Audit is not: exploit detection/mitigation, vendor-specific version tracking, a replacement for 66 | staying informed on PHP releases and security exploits. 67 |

68 | 69 |
70 |

Index

71 | 89 |
90 | 91 | 92 |
93 |

Features

94 |
    95 |
  • List known CVEs for a given version of PHP
  • 96 |
  • Check either the runtime version of PHP, or a supplied version
  • 97 |
  • Display end-of-life dates for a given version of PHP
  • 98 |
  • Display new releases for a given version of PHP with configurable specificity (latest/minor/patch) 99 |
      100 |
    • Patch: 7.3.0 -> 7.3.33
    • 101 |
    • Minor: 7.3.0 -> 7.4.27
    • 102 |
    • Latest: 7.3.0 -> 8.1.1
    • 103 |
    104 |
  • 105 |
  • Rules automatically updated twice a day. Information is sourced directly from php.net - you'll never be waiting on someone like me to merge a pull request before getting the latest patch information.
  • 106 |
  • Multiple interfaces: CLI (via PHP Composer), Docker, direct code import
  • 107 |
  • Easily scriptable for use with CI/CD workflows. All Docker/CLI outputs are in JSON format to be consumed with your favorite tools - such as jq
  • 108 |
  • Configurable exit conditions. Use CLI flags like `--fail-security` to set a failure exit code if the given version of PHP has a known CVE or is no longer receiving security updates.
  • 109 |
  • Zero dependencies
  • 110 |
111 |
112 | 113 |
114 |

Example

115 | 116 |
docker run --rm -t lightswitch05/php-version-audit:latest --version=8.0.12
117 | {
118 |     "auditVersion": "8.0.12",
119 |     "hasVulnerabilities": true,
120 |     "hasSecuritySupport": true,
121 |     "hasActiveSupport": true,
122 |     "isLatestPatchVersion": false,
123 |     "isLatestMinorVersion": false,
124 |     "isLatestVersion": false,
125 |     "latestPatchVersion": "8.0.14",
126 |     "latestMinorVersion": "8.1.1",
127 |     "latestVersion": "8.1.1",
128 |     "activeSupportEndDate": "2022-11-26T00:00:00+0000",
129 |     "securitySupportEndDate": "2023-11-26T00:00:00+0000",
130 |     "rulesLastUpdatedDate": "2022-01-18T02:13:52+0000",
131 |     "vulnerabilities": {
132 |         "CVE-2021-21707": {
133 |             "id": "CVE-2021-21707",
134 |             "baseScore": 5.3,
135 |             "publishedDate": "2021-11-29T07:15:00+0000",
136 |             "lastModifiedDate": "2022-01-04T16:12:00+0000",
137 |             "description": "In PHP versions 7.3.x below 7.3.33, 7.4.x below 7.4.26 and 8.0.x below 8.0.13, certain XML parsing functions, like simplexml_load_file(), URL-decode the filename passed to them. If that filename contains URL-encoded NUL character, this may cause the function to interpret this as the end of the filename, thus interpreting the filename differently from what the user intended, which may lead it to reading a different file than intended."
138 |         }
139 |     }
140 | }
141 |
142 |
143 | 144 |
145 |

Usage

146 | 147 |

Docker

148 |
149 |

150 | Running with docker is the preferred and easiest way to use PHP Version Audit. 151 |

152 |

153 | Check a specific version of PHP using Docker: 154 | docker run --rm -t lightswitch05/php-version-audit:latest --version=8.1.1 155 |

156 |

157 | Check the host's PHP version using Docker: 158 | docker run --rm -t lightswitch05/php-version-audit:latest --version=$(php -r 'echo phpversion();') 159 |

160 |

161 | Run behind an HTTPS proxy (for use on restricted networks). Requires a volume mount of a directory with your trusted cert (with .crt extension) - see update-ca-certificates for more details. 162 | docker run --rm -t -e https_proxy='https://your.proxy.server:port/' --volume /full/path/to/trusted/certs/directory:/usr/local/share/ca-certificates lightswitch05/php-version-audit:latest --version=8.1.1 163 |

164 | 165 |

CLI

166 |
167 |

168 | Not using docker? Not a problem. It is a couple more steps, but it is just as easy to run directly. 169 |

170 |

171 | Install the package via composer: 172 | composer require lightswitch05/php-version-audit:~1.0 173 |

174 |

175 | Execute the PHP script, checking the run-time version of PHP: 176 | ./vendor/bin/php-version-audit 177 |

178 |

179 | Produce an exit code if any CVEs are found 180 | ./vendor/bin/php-version-audit --fail-security 181 |

182 |

Direct Invocation

183 |
184 |

185 | Want to integrate with PHP Version Audit? That's certainly possible. 186 | A word caution, this is a very early release. I do not have any plans for breaking changes, 187 | but I'm also not committed to keeping the interface as-is if there are new features to implement. 188 | Docker/CLI is certainly the preferred method over direct invocation. 189 | 190 | $phpVersionAudit = new lightswitch05\PhpVersionAudit\Application(phpversion(), false);
191 | $phpVersionAudit->hasVulnerabilities(); #=> true
192 | $phpVersionAudit->getLatestPatchVersion(); #=> '8.1.1'
193 |
194 |

195 | 196 |

JSON Rules

197 |
198 |

199 | The data used to drive PHP Version Audit is automatically updated on a regular basis 200 | and is hosted on GitHub pages. This is the real meat-and-potatoes of PHP Version Audit, 201 | and you can consume it directly for use in other tools. If you choose to do this, 202 | please respect the project license by giving proper attribution notices. 203 | Also, I ask any implementations to read the lastUpdatedDate and fail if it has become out of date (2+ weeks). 204 | This should not happen since it is automatically updated… but we all know how fragile software is. 205 |

206 |

207 | Get the latest PHP 8.1 release version directly from the rules using 208 | curl and jq: 209 | 210 | curl -s https://www.github.developerdan.com/php-version-audit/rules-v1.json | jq '.latestVersions["8.1"]' 211 | 212 |

213 | 214 |

Options

215 |
216 |
217 |
--help
218 |
show arguments help message and exit.
219 |
--version=VERSION
220 |
set the PHP Version to run against. Defaults to the runtime version. This is required when running with docker.
221 |
--fail-security
222 |
generate a 10 exit code if any CVEs are found, or security support has ended.
223 |
--fail-support
224 |
generate a 20 exit code if the version of PHP no longer gets active (bug) support.
225 |
--fail-patch
226 |
generate a 30 exit code if there is a newer patch-level release.
227 |
--fail-latest
228 |
generate a 40 exit code if there is a newer release.
229 |
--no-update
230 |
do not download the latest rules. NOT RECOMMENDED!
231 |
--silent
232 |
do not write any error messages to STDERR.
233 |
--v
234 |
Set verbosity. v=warnings, vv=info, vvv=debug. Default is error. All logging writes to STDERR.
235 |
236 |
237 | 238 |
239 |

Output

240 |
241 |
• auditVersion: string
242 |
The version of PHP that is being audited.
243 |
• hasVulnerabilities: bool
244 |
If the auditVersion has any known CVEs or not.
245 |
• hasSecuritySupport: bool
246 |
If the auditVersion is still receiving security updates.
247 |
• hasActiveSupport: bool
248 |
If the auditVersion is still receiving active support (bug updates).
249 |
• isLatestPatchVersion: bool
250 |
If auditVersion is the latest patch-level release (8.0.x).
251 |
• isLatestMinorVersion: bool
252 |
If auditVersion is the latest minor-level release (8.x.x).
253 |
• isLatestVersion: bool
254 |
If auditVersion is the latest release (x.x.x).
255 |
• latestPatchVersion: string
256 |
The latest patch-level version for auditVersion.
257 |
• latestMinorVersion: string
258 |
The latest minor-level version for auditVersion.
259 |
• latestVersion: string
260 |
The latest PHP version.
261 |
• activeSupportEndDate: string|null
262 |
ISO8601 formatted date for the end of active support for auditVersion (bug fixes).
263 |
• securitySupportEndDate: string
264 |
ISO8601 formatted date for the end of security support for auditVersion.
265 |
• rulesLastUpdatedDate: string
266 |
ISO8601 formatted date for the last time the rules were auto-updated (twice a day).
267 |
• vulnerabilities: object
268 |
CVEs known to affect auditVersion with details about the CVE. CVE Details might be null for recently discovered CVEs.
269 |
270 |
271 |
272 |

Project Goals

273 |
    274 |
  • 275 | Always use update-to-date information and fail if it becomes too stale. 276 | Since this tool is designed to help its users stay informed, it must in turn fail if it becomes outdated.
  • 277 |
  • 278 | Fail if the requested information is unavailable. ex. getting the support end date of PHP version 6.0, or 5.7.0. 279 | Again, since this tool is designed to help its users stay informed, it must in turn fail if the requested information is unavailable.
  • 280 |
  • Work in both open and closed networks (as long as the tool is up-to-date).
  • 281 |
  • Minimal footprint and dependencies.
  • 282 |
  • Runtime support for the oldest supported version of PHP. 283 | If you are using this tool with an unsupported version of PHP, 284 | then you already have all the answers that this tool can give you: Yes, you have vulnerabilities and are out of date. 285 | Of course that is just for the run-time, it is still the goal of this project to supply information about any reasonable version of PHP.
  • 286 |
287 |
288 |
289 |

License & Acknowledgments

290 |
    291 |
  • 292 | This project is released under the Apache License 2.0. 293 |
  • 294 |
  • 295 | The accuracy of the information provided by this project cannot be verified or guaranteed. 296 | All functions are provided as convenience only and should not be relied on for accuracy or punctuality. 297 |
  • 298 |
  • 299 | The logo was created using Colin Viebrock's PHP Logo as the base image, 300 | released under Creative Commons Attribution-Share Alike 4.0 International. 301 | The logo has been modified from its original form to include overlay graphics. 302 |
  • 303 |
  • 304 | This project and the use of the modified PHP logo is not endorsed by Colin Viebrock. 305 |
  • 306 |
  • 307 | This project and the use of the PHP name is not endorsed by The PHP Group. 308 |
  • 309 |
  • 310 | CVE details and descriptions are downloaded from National Institute of Standard and Technology's National Vulnerability Database. 311 | This project and the use of CVE information is not endorsed by NIST or the NVD. 312 | CVE details are provided as convenience only. The accuracy of the information cannot be verified. 313 |
  • 314 |
  • 315 | PHP release details and support dates are generated from ChangeLogs 316 | (4, 317 | 5, 318 | 7, 319 | 8) 320 | as well as Supported Versions and EOL dates. 321 | The accuracy of the information cannot be verified. 322 |
  • 323 |
324 |
325 |
326 |
Copyright © 2022 Daniel White
327 | 328 | 329 | -------------------------------------------------------------------------------- /docs/php-version-audit-logo-inkscape.svg: -------------------------------------------------------------------------------- 1 | 2 | 42 | PHP Version Audit Logo 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | PHP Version Audit Logo 53 | 54 | 55 | Daniel White 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 66 | 67 | 68 | Copyright Daniel White 2019 - All rights reserved. 69 | 70 | 71 | 2019 72 | https://github.com/lightswitch05/php-version-audit 73 | 74 | 82 | 83 | 92 | 96 | 106 | 110 | 114 | 118 | 122 | 125 | 129 | 132 | 136 | 137 | 138 | 146 | 148 | 151 | 153 | 155 | 160 | 161 | 162 | 163 | 164 | 166 | 169 | 172 | 177 | 178 | 179 | 180 | 182 | 185 | 188 | 193 | 194 | 197 | 202 | 203 | 206 | 211 | 212 | 215 | 220 | 221 | 224 | 229 | 230 | 233 | 238 | 239 | 240 | 241 | 308 | 309 | 310 | 311 | 312 | 337 | 338 | 339 | 340 | 341 | --------------------------------------------------------------------------------