feat(guards): add GR-SEC-010 — output escaping enforcement in views

Add architecture test ViewLayerOutputEscapingContractTest that flags
raw <?php echo in .phtml files. Legitimate uses (pre-built ARIA attrs
from navActive(), hardcoded role values, pre-rendered HTML fragments)
must carry an inline // raw-html-ok: reason marker.

Annotated all 11 existing raw echo sites with justification markers.
Added guard to catalog, enforcement map (automated), CLAUDE.md security
section, and never-do-this list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:08:25 +02:00
parent 9a4d59eb4c
commit 576a0c51cd
12 changed files with 150 additions and 11 deletions

View File

@@ -0,0 +1,123 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* Views (.phtml) MUST use e() for HTML output — raw echo is forbidden (GR-SEC-010).
*
* Allowed exceptions (must be marked with an inline comment):
* <?php echo $var; ?> // raw-html-ok: reason
*
* Typical justified uses: pre-escaped ARIA attributes from navActive(), hardcoded
* HTML-safe ternaries (e.g. 'aria-current="page"'), or pre-built HTML fragments
* from shared helpers. Each raw echo MUST carry the "raw-html-ok" marker.
*/
class ViewLayerOutputEscapingContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* Regex matching <?php echo (with optional whitespace variations).
* Does NOT match <?php e( which is the escaped helper.
*/
private const ECHO_PATTERN = '/<\?php\s+echo\b/';
/** Inline marker that excuses a raw echo on the same line. */
private const EXCEPTION_MARKER = 'raw-html-ok';
public function testCorePagesViewsUseEscapedOutput(): void
{
$violations = $this->scanForRawEcho('pages');
self::assertSame(
[],
$violations,
"Raw <?php echo found in core pages views without '" . self::EXCEPTION_MARKER . "' marker:\n" . implode("\n", $violations),
);
}
public function testCoreTemplatesUseEscapedOutput(): void
{
$violations = $this->scanForRawEcho('templates');
self::assertSame(
[],
$violations,
"Raw <?php echo found in core templates without '" . self::EXCEPTION_MARKER . "' marker:\n" . implode("\n", $violations),
);
}
public function testModuleViewsUseEscapedOutput(): void
{
$root = $this->projectRootPath();
$modulesDir = $root . '/modules';
if (!is_dir($modulesDir)) {
self::markTestSkipped('modules/ directory missing.');
}
$allViolations = [];
foreach (scandir($modulesDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
continue;
}
foreach (['pages', 'templates'] as $subDir) {
$rel = 'modules/' . $entry . '/' . $subDir;
if (is_dir($root . '/' . $rel)) {
$allViolations = array_merge($allViolations, $this->scanForRawEcho($rel));
}
}
}
sort($allViolations);
self::assertSame(
[],
$allViolations,
"Raw <?php echo found in module views without '" . self::EXCEPTION_MARKER . "' marker:\n" . implode("\n", $allViolations),
);
}
/**
* Scan .phtml files for raw echo statements, returning violations as "file:line: content".
*
* @return list<string>
*/
private function scanForRawEcho(string $relativeDirectory): array
{
$root = $this->projectRootPath();
$basePath = $root . '/' . $relativeDirectory;
$this->assertDirectoryExists($basePath, 'Directory not found: ' . $relativeDirectory);
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($basePath),
);
$violations = [];
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (!$file->isFile() || $file->getExtension() !== 'phtml') {
continue;
}
$content = file_get_contents($file->getPathname());
$this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname());
$lines = explode("\n", $content);
foreach ($lines as $lineNumber => $line) {
if (!preg_match(self::ECHO_PATTERN, $line)) {
continue;
}
if (str_contains($line, self::EXCEPTION_MARKER)) {
continue;
}
$relativePath = str_replace($root . '/', '', $file->getPathname());
$violations[] = sprintf('%s:%d: %s', $relativePath, $lineNumber + 1, trim($line));
}
}
sort($violations);
return $violations;
}
}