waynestate / proctor
A framework-free PHP CLI that measures a PHP app against the version policy.
Requires
- php: ^8.3
- composer/semver: ^3.4
- nikic/php-parser: ^5.6
- symfony/console: ^7.1
- symfony/yaml: ^7.1
Requires (Dev)
- humbug/box: 4.7.1
- laravel/pint: ^1.32
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- waynestate/pinstripe: ^0.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
PROCTOR
Policy Rules Of Code, Tested On Release. The invigilator your composer.lock didn't know it needed: no phones, no EOL PHP.
PROCTOR is a framework-free PHP CLI that measures a PHP application against
the WSU version policy. It reads the app's composer.json and composer.lock,
decides the support state of the app's PHP floor from a policy file, and
reports each check as pass, warning, info or fail. The checks and their rules
come from the stack's version policy decision and from the policy file
passed with --policy; PROCTOR itself holds no policy of its own.
Checks: composer ci (the same command runs on GitLab and GitHub). Front page: site/index.html.
On Citadel it is served by the raw-PHP deploy layer in deploy/: a SQUIRE kit whose front controller serves site/ behind HELMET's security headers. For local development, keep serving site/ directly.
Note
This repository is published from Wayne State University's internal GitLab and mirrored here. Changes land there first; Wayne State developers open issues and merge requests on the internal project.
Install
composer require waynestate/proctor --dev
The command is then vendor/bin/proctor.
Single-file download
PROCTOR can also be built as a single-file PHAR, so PREFLIGHT and apps without Composer access can run it as one file. Build it with the script for your OS (both need Docker, and no PHP or Composer on the host):
Windows (PowerShell):
.\scripts\build-phar.ps1
Linux / macOS:
scripts/build-phar.sh
Each script writes build/proctor.phar and verifies it by printing PROCTOR's
version. The phar is not published yet (there is no release step), so build it
from a checkout rather than downloading it.
Usage
vendor/bin/proctor check [--path=.] [--format=text|json|codequality|sarif] [--policy=<file>] [--today=YYYY-MM-DD] [--php-runtime=X.Y.Z]
--path: path to the application to check (default.).--format: output format:text(default),json,codequality, orsarif.--policy: path to the CREED-format policy file. Optional: without it, PROCTOR resolves the policy from thePROCTOR_POLICYenvironment variable, then the app'svendor/waynestate/creed/policy/policy.json, then the copy bundled with PROCTOR (see Policy).--today: override today's date (YYYY-MM-DD) so runs are deterministic.--php-runtime: the PHP version that will serve the app (X.Y.Z), used by theunpatchedcheck. CI should pass the deploy image's PHP, not the runner's.
Checks
php-floor: fails when the lowest PHP version allowed byrequire.phpis past security support plus the grace window, when composer.json has no usable php constraint, or when there is no composer.json at all.unpatched: warns when a locked component is behind the policy'slatest_patchfor its version line, and passes when it is current. Reads laravel from composer.lock (laravel/framework), vue and inertia from package-lock.json (vue,@inertiajs/vue3), and the PHP runtime from--php-runtime. No finding when the component, its version line, orlatest_patchis absent from the policy.unbounded-constraints: fails for every runtime constraint that is*or names a dev- branch.composer-config: warns when composer.json opens a door Composer closes by default:allow-pluginsset totrue,secure-httpset tofalse,lockset tofalse, oraudit.block-insecureset tofalse; notes whenrequire.phpis set withoutconfig.platform.php. Warning and info only, never a fail, so an app that passes today keeps passing.lock-committed: fails when composer.lock is missing from the app path.portability: warns when code hard-codes a platform fact: an absolute host path, a hard-coded proxy address, a local-disk session store, or a php_value/php_flag line in .htaccess. PHP files are matched on the syntax tree (nikic/php-parser with names resolved), not with regular expressions, so a comment or string that merely mentions a banned call is not flagged and a call split over several lines, or hidden behind ause function ... as ...alias, is. A call through a variable ($f()) is reported as an info note, never as a pass.mysql56: warns when migrations or app code use MySQL features the shared 5.6 server rejects: JSON columns and JSON queries, generated columns,renameColumn/renameIndex, vector, invisible and spatial columns,Schema::hasColumn-style introspection, raw SQL with 5.7+ syntax, the upsert row alias, indexed strings over 191 characters, date columns defaulting to the current time, and thedb:show/db:monitorcommands. Citadel's shared MySQL server is 5.6, so migrations must avoid newer features. Warnings, not failures: an app on its own newer database is not affected. Fix by avoiding the feature (a rawALTER TABLE ... CHANGEinstead ofrenameColumn,timestampinstead of a date column defaulting to now,Schema::defaultStringLength(191)inboot()) or by moving the app to its own database.destructive-deploy: fails when a deploy script runs an artisan command that drops or rebuilds the database (migrate:fresh,migrate:refresh,migrate:reset,db:wipe), which erases production data on every deploy. Scans.shand.phpfiles under a top-leveldeployments/,deploy_scripts/,deploy/orscripts/directory, the top-levelbuild.sh,release.shand.gitlab-ci.yml, and thescriptssection of composer.json; shell comments are ignored. Fix by running plainphp artisan migrate --forcein the deploy.ci-pipelines: for stack packages (composer.jsonnamestarting withwaynestate/), fails when.gitlab-ci.ymlor.github/workflows/ci.ymlis missing or runs nocomposer ci; for any other project, a missing or incomplete.gitlab-ci.ymlis a warning and.github/workflows/ci.ymlis optional. Warns when a GitHubuses:ref is not a 40-hex commit SHA. The proctor fails the stack's own exam halls, and only leaves a note for everyone else's.composer-audit: fails for advisories at or above the policy'saudit_severity_threshold, warns for lower ones, and reports info when composer or the lock file is absent.composer-version: fails when the Composer on PATH carries the command-injection holes CVE-2024-35241 and CVE-2024-35242 (2.2.x below 2.2.24, or any other 2.x below 2.7.7) or is Composer 1, which is end of life; warns when composer cannot be run. The invigilator checks its own pen: GHSA-47f6-5gq3-vx9c and GHSA-v9qv-c7wm-wgmf.repo-url-credentials: warns when a credential is written into a package source URL: a deploy token in the userinfo of a composer.json repository URL or a composer.lock source/dist URL (https://gitlab+deploy-token-12:***@host/group/lib.git), a bare token user of 20+ characters, aprivate_token,access_token,tokenorpasswordquery parameter, or a.npmrcline that sets_authToken,_author_passwordto a literal value (${SOME_VAR}is fine). The secret is masked as***in the message, so the report itself is safe to hand back. Warning, not failure: about sixteen production apps carry such URLs today (D-034). Fix by moving the token toauth.json, which is gitignored, or a CI variable, and using one registry URL without credentials. The proctor spots the answer key taped inside the desk before the student does.overrides: fails for every expired entry in policy-overrides.json.policy-age: warns when the policy in use is more than 60 days old (itsrefreshed_ondate is more than 60 days before--today), or when a non-bundled policy has norefreshed_ondate. Warning only: an old policy never fails a run, and the bundled policy may omit the date. It exists so a stopped CREED refresh robot is visible instead of silent.
Exit codes
0: pass (warnings allowed)1: one or more failures2: tool error (bad options, unreadable policy, a present but malformed or unreadable composer.json, or an unreadable overrides file). A missing composer.json is not a tool error: the app gets a verdict, with php-floor failing.
%%{init: {'theme':'base','themeVariables':{'fontFamily':'Helvetica, Arial, sans-serif','primaryColor':'#F4F1EA','primaryTextColor':'#1f2a33','primaryBorderColor':'#0C5449','lineColor':'#8A9A94'}}}%%
flowchart LR
run["proctor check"] --> ok{"options and policy readable?"}
ok -- no --> two["exit 2: the proctor tripped"]
ok -- yes --> fails{"any FAIL?"}
fails -- yes --> one["exit 1: resit"]
fails -- no --> zero["exit 0: pass, warnings allowed"]
classDef pass fill:#0C5449,stroke:#0C5449,color:#F4F1EA
classDef fail fill:#9B1C2E,stroke:#9B1C2E,color:#F4F1EA
classDef trip fill:#F4F1EA,stroke:#9B1C2E,color:#9B1C2E,stroke-dasharray:4 3
class zero pass
class one fail
class two trip
Loading
Formats
text: a header line with the tool and the versions of the external tools used (composer), then one line naming the policy in use (origin, path, refresh date), then one line per finding, then one line per override, then a RESULT line.json: one machine-readable object with tool, version, path, today, result, exit, tools, policy (origin, path, refreshed_on, age_days), findings and overrides.codequality: GitLab's Code Quality JSON: a single array of findings, so a merge request shows them.sarif: a SARIF 2.1.0 log: one run, one rule per check, one result per non-pass finding, so GitHub code scanning and other SARIF viewers show them.
Rule ids
Every finding carries a stable, namespaced rule id: proctor/<check-id> for a
check's default finding, proctor/<check-id>/<rule> for a named rule. The id
is the finding's identity in the machine formats: rule_id in json,
ruleId in sarif, and check_name in codequality. A rule keeps the
same id across runs and across formats even when its message changes.
The full set, grouped by check:
| Check | Rule id |
|---|---|
php-floor |
proctor/php-floor |
unpatched |
proctor/unpatched |
lock-committed |
proctor/lock-committed |
destructive-deploy |
proctor/destructive-deploy |
overrides |
proctor/overrides |
unbounded-constraints |
proctor/unbounded-constraints |
unbounded-constraints |
proctor/unbounded-constraints/unbounded |
unbounded-constraints |
proctor/unbounded-constraints/repository |
composer-config |
proctor/composer-config |
composer-config |
proctor/composer-config/allow-plugins-all |
composer-config |
proctor/composer-config/secure-http-off |
composer-config |
proctor/composer-config/lock-off |
composer-config |
proctor/composer-config/audit-blocking-off |
composer-config |
proctor/composer-config/platform-php-unset |
portability |
proctor/portability |
portability |
proctor/portability/host-path |
portability |
proctor/portability/proxy-address |
portability |
proctor/portability/local-session |
portability |
proctor/portability/htaccess-php-setting |
portability |
proctor/portability/parse-error |
portability |
proctor/portability/dynamic-call |
mysql56 |
proctor/mysql56 |
mysql56 |
proctor/mysql56/json |
mysql56 |
proctor/mysql56/generated |
mysql56 |
proctor/mysql56/rename |
mysql56 |
proctor/mysql56/newer-column |
mysql56 |
proctor/mysql56/introspection |
mysql56 |
proctor/mysql56/raw-sql |
mysql56 |
proctor/mysql56/string-length |
mysql56 |
proctor/mysql56/current-date |
mysql56 |
proctor/mysql56/command |
mysql56 |
proctor/mysql56/upsert-alias |
mysql56 |
proctor/mysql56/string-default |
mysql56 |
proctor/mysql56/parse-error |
ci-pipelines |
proctor/ci-pipelines |
ci-pipelines |
proctor/ci-pipelines/gitlab-missing |
ci-pipelines |
proctor/ci-pipelines/gitlab-parse |
ci-pipelines |
proctor/ci-pipelines/gitlab-no-ci |
ci-pipelines |
proctor/ci-pipelines/github-missing |
ci-pipelines |
proctor/ci-pipelines/github-parse |
ci-pipelines |
proctor/ci-pipelines/github-no-ci |
ci-pipelines |
proctor/ci-pipelines/unpinned-uses |
composer-audit |
proctor/composer-audit |
composer-audit |
proctor/composer-audit/advisory |
composer-audit |
proctor/composer-audit/abandoned |
composer-audit |
proctor/composer-audit/skipped |
composer-audit |
proctor/composer-audit/could-not-run |
composer-version |
proctor/composer-version |
composer-version |
proctor/composer-version/not-found |
composer-version |
proctor/composer-version/could-not-determine |
repo-url-credentials |
proctor/repo-url-credentials |
policy-age |
proctor/policy-age |
tests/RuleIdContractTest.php keeps this table in step with the checks: a rule
id that is emitted but not listed here fails the suite.
GitHub code scanning
The sarif format feeds GitHub code scanning on the GitHub mirror. Upload the
log with the official action, pinned to a full commit SHA:
name: proctor on: [push, pull_request] permissions: security-events: write jobs: proctor: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.4' - run: composer require waynestate/proctor --dev # policy.json is the CREED policy file - run: vendor/bin/proctor check --format=sarif --policy=policy.json > proctor.sarif - uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: proctor.sarif
PROCTOR now files the mark sheet in the format the head office already reads. No more translating the exam results by hand.
Overrides
An app can carry a policy-overrides.json file next to its composer.json:
[
{
"check": "lock-committed",
"reason": "lock is generated by CI and not committed",
"expires": "2027-01-31",
"approved_by": "jane@example.wayne.edu"
}
]
An expired override fails the overrides check. An active override turns the
check's fail findings into warnings. An override never waives a failure whose
state is EOL plus grace (eol_grace_expired); that failure stands (the stack's version policy decision).
Policy
--policy takes a CREED-format policy file: schema_version 1, component
versions with released, active_until and security_until dates, and rules
(audit_severity_threshold, banned_constraints, eol_grace_days). The
policy-file wiring to waynestate/creed is described below.
Without --policy, PROCTOR resolves the policy in this order (first match
wins):
- the
PROCTOR_POLICYenvironment variable, naming a policy file; <app>/vendor/waynestate/creed/policy/policy.json, when it exists and is at least as fresh as the bundled copy (same or newerrefreshed_on);- the policy bundled with PROCTOR (
src/Creed/policy.json).
An app that installs CREED gets its policy automatically:
composer require waynestate/creed --dev
A vendor copy that cannot be read or parsed is skipped with a note on stderr
and the run continues with the next source; a vendor copy older than the
bundled one is skipped the same way. An explicit source (--policy or
PROCTOR_POLICY) that points at a missing or invalid file is still an error
(exit 2).
Every report names the policy it used: origin (option, env, vendor or
bundled), path, refreshed_on and the age in days. The policy-age check
warns when the policy is more than 60 days old, or when a non-bundled policy
has no refreshed_on date, so a stopped CREED refresh is visible instead of
silent.
PROCTOR carries a generated copy of the CREED reader under src/Creed/
(namespace Waynestate\Proctor\Creed), so it never depends on the CREED
package at runtime. composer creed-copy:check verifies the copy against the
CREED checkout named by CREED_PATH (default ../creed); refresh it by
running CREED's bin/export.php against src/Creed/.
Proof
82 Wayne State applications run through PROCTOR on 2026-10-28: 0 passed, 81 flagged, 1 tool error.
Each app was copied with .git, auth.json and every .env removed and checked inside php:8.4-cli, with Wayne
State's servers out of reach, using proctor check --format=json --policy=<CREED policy> --today=2026-10-28.
Flagged means exit 1 with a report naming each problem: PROCTOR doing its job.
| Sitting | Flagged | Tool errors |
|---|---|---|
| First (0.2.0, the day before its tag) | 51 | 31 |
| Retake (0.2.0 plus "apps without composer.json get a verdict") | 81 | 1 |
The first sitting found 31 plain PHP apps PROCTOR refused with exit 2; that was fixed with a fixture test and every
app sat again. Most common fails on the retake: php-floor (76 apps), lock-committed (30), composer-audit
(20), unbounded-constraints (13). The one tool error is the PHP parser running out of the default 128 MB on a very
large tree; the fix is queued with a test. The apps are counted, never named. The breakdown, the command and what
the run does not prove: site/proof.html.
Eighty-one papers handed back in red. The proctor saw that.
Running the tests in Docker
Maintainers: how to build, test and release this project, and where its data comes from, is in MAINTAINING.md.
No PHP is needed on the host:
docker run --rm -v "${PWD}:/app" -w /app composer:2 composer install --no-interaction
docker run --rm -v "${PWD}:/app" -w /app php:8.4-cli vendor/bin/phpunit
The same works with php:8.3-cli in place of php:8.4-cli.
Strongly recommended: a git.wayne.edu instance runner.
Licence
MIT, Wayne State University, Enterprise Applications.
Seated in the same hallWayneUI and the stack. Each one sits its own exam.
scripts/readme-art. Pencils down.

