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:
123
tests/Architecture/ViewLayerOutputEscapingContractTest.php
Normal file
123
tests/Architecture/ViewLayerOutputEscapingContractTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user