From 19a523693ee51f69ef02f39612873af86a8d4a49 Mon Sep 17 00:00:00 2001 From: Josiah King Date: Mon, 10 Aug 2026 10:34:48 +0100 Subject: [PATCH] security: add supply-chain scanning foundation --- .github/dependabot.yml | 19 ++ .github/workflows/quality.yml | 3 + CHANGELOG.md | 1 + .../EvolvePhp2SupplyChainSecurityTest.php | 214 ++++++++++++++++++ workspace/README.md | 36 +++ workspace/composer.json | 6 + workspace/tools/check-licenses.php | 69 ++++++ 7 files changed, 348 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 tests/Documentation/EvolvePhp2SupplyChainSecurityTest.php create mode 100644 workspace/tools/check-licenses.php diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..83afd12 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 + +updates: + - package-ecosystem: "composer" + directory: "/workspace" + schedule: + interval: "weekly" + ignore: + - dependency-name: "evolvephp/contracts" + - dependency-name: "evolvephp/core" + - dependency-name: "evolvephp/http" + - dependency-name: "evolvephp/module" + - dependency-name: "evolvephp/plugin" + - dependency-name: "evolvephp/testing" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 6434166..38d664c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -40,6 +40,9 @@ jobs: - name: Install workspace dependencies run: composer --working-dir=workspace install --no-interaction --no-progress --prefer-dist + - name: Run workspace supply-chain checks + run: composer --working-dir=workspace supply-chain + - name: Run root policy tests run: php workspace/vendor/bin/phpunit --configuration phpunit.xml.dist tests/Architecture tests/Documentation diff --git a/CHANGELOG.md b/CHANGELOG.md index 85006be..036e749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Documentation and governance +- Added Phase 2.9A supply-chain security foundation with Composer lockfile security audit enforcement, abandoned-package failure, locked production and development licence-policy checks for MIT, BSD-3-Clause and Apache-2.0, repository-owned Dependabot version-update configuration, Policy job enforcement, and documentation of GitHub setting boundaries. - Added Phase 2.8 developer-experience foundation with EditorConfig, VS Code extension recommendations, portable VS Code settings and portable task commands, PHP 8.4 language-analysis targeting, canonical Composer-script reuse, explicit non-mutating quality checks versus the Style Fix mutating task, no local executable paths, and no runtime debugging configuration. - Added Phase 2.7B repository governance evidence finalization: changed the GitHub default branch to `2.x`, activated repository rulesets for `master` and `2.x`, preserved `master` as the EvolvePHP 1 legacy line, required PR-based change on both branches, blocked deletion and force pushes, enforced strict/up-to-date required status checks on `2.x` for `Policy (PHP 8.4)`, `Workspace quality (PHP 8.4)` and `Workspace quality (PHP 8.5)`, and made no branch rename or deletion. - Added Phase 2.7A repository-owned branch-governance policy foundation for EvolvePHP 2 development on `2.x`, preserved EvolvePHP 1 maintenance on `master`, explicit separation of modern development and legacy maintenance, and preparation for a later default-branch and ruleset transition. diff --git a/tests/Documentation/EvolvePhp2SupplyChainSecurityTest.php b/tests/Documentation/EvolvePhp2SupplyChainSecurityTest.php new file mode 100644 index 0000000..0cb84e5 --- /dev/null +++ b/tests/Documentation/EvolvePhp2SupplyChainSecurityTest.php @@ -0,0 +1,214 @@ +root = dirname(__DIR__, 2); + } + + public function testDependabotConfigurationDeclaresRepositoryOwnedVersionUpdates(): void + { + $content = $this->readProjectFile('.github/dependabot.yml'); + + $this->assertMatchesPattern('/^version:\s*2\s*$/m', $content); + $this->assertMatchesPattern('/package-ecosystem:\s*"composer"|package-ecosystem:\s*composer/', $content); + $this->assertMatchesPattern('/directory:\s*"\/workspace"|directory:\s*\/workspace/', $content); + $this->assertMatchesPattern('/package-ecosystem:\s*"github-actions"|package-ecosystem:\s*github-actions/', $content); + $this->assertMatchesPattern('/directory:\s*"\/"|directory:\s*\/\s*$/m', $content); + $this->assertSame(2, preg_match_all('/interval:\s*"weekly"|interval:\s*weekly/', $content, $matches)); + + foreach (array( + '/registr(?:y|ies):/i', + '/token|password|secret/i', + '/auto.?merge/i', + '/target-branch:/i', + ) as $pattern) { + $this->assertDoesNotMatchPattern($pattern, $content); + } + + foreach ($this->evolvePathPackages() as $package) { + $this->assertMatchesPattern('/"' . preg_quote($package, '/') . '"|\b' . preg_quote($package, '/') . '\b/', $content); + } + } + + public function testWorkspaceComposerDeclaresCanonicalSupplyChainScripts(): void + { + $manifest = $this->readJsonFile('workspace/composer.json'); + + $this->assertArrayHasKey('scripts', $manifest); + $this->assertArrayHasKey('security:audit', $manifest['scripts']); + $this->assertArrayHasKey('licenses:check', $manifest['scripts']); + $this->assertArrayHasKey('supply-chain', $manifest['scripts']); + $this->assertSame('@composer audit --locked --abandoned=fail', $manifest['scripts']['security:audit']); + $this->assertSame('@php tools/check-licenses.php', $manifest['scripts']['licenses:check']); + $this->assertSame(array('@security:audit', '@licenses:check'), $manifest['scripts']['supply-chain']); + $this->assertSame(array('@architecture', '@analyse', '@style:check', '@test'), $manifest['scripts']['quality']); + } + + public function testLicenceCheckerEnforcesLockedProductionAndDevelopmentPolicy(): void + { + $content = $this->readProjectFile('workspace/tools/check-licenses.php'); + + foreach (array('MIT', 'BSD-3-Clause', 'Apache-2.0') as $license) { + $this->assertMatchesPattern('/[\'"]' . preg_quote($license, '/') . '[\'"]/', $content); + } + + foreach (array( + '/packages-dev/', + '/packages/', + '/composer\.lock/', + '/json_decode/', + '/license/', + '/version/', + '/exit\(1\)|return\s+1/', + '/missing/i', + '/unapproved|not approved/i', + ) as $pattern) { + $this->assertMatchesPattern($pattern, $content); + } + + foreach (array( + '/curl_|file_get_contents\(\s*[\'"]https?:/i', + '/jetbrains\/phpstorm-stubs/i', + '/ignore|allow-failure|bypass|exception/i', + '/GPL-3\.0-only|MPL-2\.0|proprietary/', + ) as $pattern) { + $this->assertDoesNotMatchPattern($pattern, $content); + } + } + + public function testPolicyWorkflowRunsSupplyChainGateWithoutRenamingRequiredJobs(): void + { + $workflow = $this->readProjectFile('.github/workflows/quality.yml'); + $policyJob = $this->extractJob($workflow, 'policy'); + + $this->assertMatchesPattern('/name:\s*Policy \(PHP 8\.4\)/', $policyJob); + $this->assertMatchesPattern('/name:\s*Workspace quality \(PHP \$\{\{ matrix\.php \}\}\)/', $workflow); + $this->assertStringContainsString('composer --working-dir=workspace supply-chain', $policyJob); + $this->assertSame(1, preg_match_all('/composer --working-dir=workspace supply-chain/', $workflow, $matches)); + $this->assertBefore( + 'composer --working-dir=workspace install --no-interaction --no-progress --prefer-dist', + 'composer --working-dir=workspace supply-chain', + $policyJob + ); + $this->assertBefore( + 'composer --working-dir=workspace supply-chain', + 'php workspace/vendor/bin/phpunit --configuration phpunit.xml.dist tests/Architecture tests/Documentation', + $policyJob + ); + $this->assertMatchesPattern('/^permissions:\s*\R\s{2}contents:\s*read\s*$/m', $workflow); + $this->assertDoesNotMatchPattern('/composer --working-dir=workspace supply-chain/', $this->extractJob($workflow, 'workspace-quality')); + } + + public function testDocumentationRecordsSupplyChainSecurityBoundaries(): void + { + $readme = $this->readProjectFile('workspace/README.md'); + $changelog = $this->readProjectFile('CHANGELOG.md'); + + foreach (array( + '/## Supply-Chain Security/', + '/composer --working-dir=workspace security:audit/', + '/composer --working-dir=workspace licenses:check/', + '/composer --working-dir=workspace supply-chain/', + '/committed lockfile|lockfile.*committed/i', + '/abandoned packages fail|fail.*abandoned packages/i', + '/require-dev.*included|development dependencies.*included/i', + '/production and development.*packages|packages.*production and development/i', + '/MIT/', + '/BSD-3-Clause/', + '/Apache-2\.0/', + '/engineering.*policy|policy.*engineering/i', + '/not legal advice|not a legal opinion/i', + '/unknown|new licence/i', + '/deliberate review/i', + '/no advisory suppression|advisory suppression.*no/i', + '/network access|remote advisory/i', + '/quality.*distinct|distinct.*quality/i', + '/Dependabot.*\/workspace/i', + '/GitHub Actions/', + '/GitHub settings/i', + '/vulnerability alerts|security updates/i', + '/jetbrains\/phpstorm-stubs/i', + '/distribution|attribution/i', + ) as $pattern) { + $this->assertMatchesPattern($pattern, $readme); + } + + $this->assertMatchesPattern('/Phase 2\.9A/i', $changelog); + $this->assertMatchesPattern('/supply-chain/i', $changelog); + } + + private function extractJob($workflow, $job) + { + $this->assertSame(1, preg_match('/^jobs:\s*\R(?P.*)\z/ms', $workflow, $jobsMatch), 'Workflow jobs block should exist.'); + $pattern = '/^\s{2}' . preg_quote($job, '/') . ':\s*\R(?P.*?)(?=^\s{2}[a-zA-Z0-9_-]+:\s*|\z)/ms'; + + $this->assertSame(1, preg_match($pattern, $jobsMatch['jobs'], $matches), 'Missing job: ' . $job); + + return $matches['job']; + } + + private function assertBefore($first, $second, $content) + { + $firstPosition = strpos($content, $first); + $secondPosition = strpos($content, $second); + + $this->assertNotFalse($firstPosition, 'Expected to find: ' . $first); + $this->assertNotFalse($secondPosition, 'Expected to find: ' . $second); + $this->assertLessThan($secondPosition, $firstPosition, $first . ' should appear before ' . $second); + } + + private function evolvePathPackages() + { + return array( + 'evolvephp/contracts', + 'evolvephp/core', + 'evolvephp/http', + 'evolvephp/module', + 'evolvephp/plugin', + 'evolvephp/testing', + ); + } + + private function projectPath($path) + { + return $this->root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $path); + } + + private function readProjectFile($path) + { + $fullPath = $this->projectPath($path); + $this->assertFileExists($fullPath, $path . ' should exist before it is read.'); + + $content = file_get_contents($fullPath); + $this->assertNotFalse($content, $path . ' should be readable.'); + + return $content; + } + + private function readJsonFile($path) + { + $content = $this->readProjectFile($path); + $decoded = json_decode($content, true); + + $this->assertSame(JSON_ERROR_NONE, json_last_error(), $path . ' should contain valid JSON: ' . json_last_error_msg()); + $this->assertIsArray($decoded); + + return $decoded; + } + + private function assertMatchesPattern($pattern, $content) + { + $this->assertSame(1, preg_match($pattern, $content), 'Failed asserting that content matches ' . $pattern); + } + + private function assertDoesNotMatchPattern($pattern, $content) + { + $this->assertSame(0, preg_match($pattern, $content), 'Failed asserting that content does not match ' . $pattern); + } +} diff --git a/workspace/README.md b/workspace/README.md index 21c1ee8..7466100 100644 --- a/workspace/README.md +++ b/workspace/README.md @@ -118,6 +118,42 @@ composer --working-dir=workspace quality `quality` runs `architecture`, `analyse`, `style:check` and `test`, in that order. `style:fix` remains separate because it is mutating. +## Supply-Chain Security + +Run the Composer lockfile security audit: + +```bash +composer --working-dir=workspace security:audit +``` + +`security:audit` checks the committed `workspace/composer.lock` dependency set through Composer audit, keeps `require-dev` included intentionally and fails on abandoned packages. Vulnerability, malware and dependency-policy findings must not be hidden by advisory suppression without an explicit future security decision. There is no advisory suppression list in this foundation. + +Run the locked dependency licence-policy check: + +```bash +composer --working-dir=workspace licenses:check +``` + +`licenses:check` validates locked production and development packages from both `packages` and `packages-dev`. The approved locked dependency licence identifiers are: + +- MIT +- BSD-3-Clause +- Apache-2.0 + +This allowlist reflects reviewed dependencies in the committed lockfile. `Apache-2.0` was deliberately reviewed because `jetbrains/phpstorm-stubs` currently appears in `packages-dev`. The allowlist is a repository engineering dependency-admission policy, not legal advice or a general legal compatibility statement. New or unknown licence identifiers fail closed and require deliberate review before acceptance. Approval here does not remove distribution or attribution obligations that may apply if future release artifacts redistribute third-party material. + +Run the aggregate supply-chain gate: + +```bash +composer --working-dir=workspace supply-chain +``` + +`supply-chain` runs `security:audit` and then `licenses:check`. It is distinct from deterministic normal Composer quality tooling: `quality` remains the architecture, static-analysis, style and PHPUnit aggregate, while `supply-chain` may require network access for fresh remote advisory data. + +CI executes the supply-chain gate through the required `Policy (PHP 8.4)` job after workspace dependencies are installed and before root policy tests. Dependabot version updates are repository-owned in `.github/dependabot.yml`: Composer updates watch `/workspace`, GitHub Actions updates watch `/`, and internal EvolvePHP path packages are excluded from Composer version-update pull requests. + +Dependabot alerts and Dependabot security updates are GitHub settings. `.github/dependabot.yml` alone does not prove those GitHub settings are enabled. + ## VS Code Developer Experience VS Code is optional developer tooling, not framework runtime configuration. The repository root is the VS Code workspace; no separate `.code-workspace` file is required for this phase. diff --git a/workspace/composer.json b/workspace/composer.json index 482359e..a26683f 100644 --- a/workspace/composer.json +++ b/workspace/composer.json @@ -39,14 +39,20 @@ "scripts": { "architecture": "@php vendor/bin/deptrac analyse --config-file=deptrac.php --no-progress --report-uncovered --fail-on-uncovered", "analyse": "@php vendor/bin/phpstan analyse --configuration phpstan.neon.dist --no-progress", + "licenses:check": "@php tools/check-licenses.php", "quality": [ "@architecture", "@analyse", "@style:check", "@test" ], + "security:audit": "@composer audit --locked --abandoned=fail", "style:check": "@php vendor/bin/php-cs-fixer check --config=.php-cs-fixer.dist.php --diff --verbose", "style:fix": "@php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --diff --verbose", + "supply-chain": [ + "@security:audit", + "@licenses:check" + ], "test": "@php vendor/bin/phpunit --configuration phpunit.xml.dist", "test:contracts": "@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite contracts", "test:core": "@php vendor/bin/phpunit --configuration phpunit.xml.dist --testsuite core", diff --git a/workspace/tools/check-licenses.php b/workspace/tools/check-licenses.php new file mode 100644 index 0000000..2a3581e --- /dev/null +++ b/workspace/tools/check-licenses.php @@ -0,0 +1,69 @@ +'; + $version = isset($package['version']) ? $package['version'] : ''; + $licenses = isset($package['license']) ? $package['license'] : null; + + if (!is_array($licenses) || $licenses === array()) { + $failures[] = $name . ' ' . $version . ' has missing licence data in ' . $section . '.'; + continue; + } + + foreach ($licenses as $license) { + if (!is_string($license) || !in_array($license, APPROVED_LICENSES, true)) { + $reportedLicense = is_string($license) ? $license : ''; + $failures[] = $name . ' ' . $version . ' has unapproved licence ' . $reportedLicense . ' in ' . $section . '.'; + } + } + } +} + +if ($failures !== array()) { + fwrite(STDERR, 'Composer locked dependency licence policy failed:' . PHP_EOL); + + foreach ($failures as $failure) { + fwrite(STDERR, '- ' . $failure . PHP_EOL); + } + + fwrite(STDERR, 'Approved licence identifiers: ' . implode(', ', APPROVED_LICENSES) . PHP_EOL); + exit(1); +} + +echo 'Composer locked dependency licence policy passed for packages and packages-dev.' . PHP_EOL; +exit(0);