Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
214 changes: 214 additions & 0 deletions tests/Documentation/EvolvePhp2SupplyChainSecurityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php

use PHPUnit\Framework\TestCase;

final class EvolvePhp2SupplyChainSecurityTest extends TestCase
{
private $root;

protected function setUp(): void
{
$this->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<jobs>.*)\z/ms', $workflow, $jobsMatch), 'Workflow jobs block should exist.');
$pattern = '/^\s{2}' . preg_quote($job, '/') . ':\s*\R(?P<job>.*?)(?=^\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);
}
}
36 changes: 36 additions & 0 deletions workspace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions workspace/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 69 additions & 0 deletions workspace/tools/check-licenses.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

const APPROVED_LICENSES = array(
'MIT',
'BSD-3-Clause',
'Apache-2.0',
);

$lockPath = __DIR__ . '/../composer.lock';

foreach (array_slice($argv, 1) as $argument) {
if (strpos($argument, '--lock=') === 0) {
$lockPath = substr($argument, strlen('--lock='));
}
}

$contents = file_get_contents($lockPath);

if ($contents === false) {
fwrite(STDERR, 'Unable to read Composer lockfile: ' . $lockPath . PHP_EOL);
exit(1);
}

$lock = json_decode($contents, true);

if (!is_array($lock) || json_last_error() !== JSON_ERROR_NONE) {
fwrite(STDERR, 'Malformed Composer lockfile JSON: ' . $lockPath . PHP_EOL);
exit(1);
}

$failures = array();

foreach (array('packages', 'packages-dev') as $section) {
if (!isset($lock[$section]) || !is_array($lock[$section])) {
continue;
}

foreach ($lock[$section] as $package) {
$name = isset($package['name']) ? $package['name'] : '<unknown>';
$version = isset($package['version']) ? $package['version'] : '<unknown>';
$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 : '<invalid>';
$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);