Files
rspade_system/app/RSpade/CodeQuality/Rules/PHP/ExecUsage_CodeQualityRule.php
root f6fac6c4bc Fix bin/publish: copy docs.dist from project root
Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 02:08:33 +00:00

203 lines
6.6 KiB
PHP
Executable File

<?php
namespace App\RSpade\CodeQuality\Rules\PHP;
use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
use App\RSpade\CodeQuality\Support\FileSanitizer;
class ExecUsage_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-EXEC-01';
}
public function get_name(): string
{
return 'exec() Usage Check';
}
public function get_description(): string
{
return 'Prohibits exec() function due to silent output truncation - requires proc_open() or shell_exec()';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'critical';
}
/**
* Check PHP file for exec() usage
*
* exec() has a critical limitation: it reads command output line-by-line into an array,
* which can cause silent truncation for large outputs or hit memory/buffer limits.
*
* This causes catastrophic failures where:
* - Compilation output gets truncated mid-line
* - Error messages are incomplete
* - No error/exception is thrown - the truncation is SILENT
*
* Requires proc_open() (for return code validation) or shell_exec() (simple cases).
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Skip vendor directories
if (str_contains($file_path, '/vendor/')) {
return;
}
// Skip CodeQuality directory
if (str_contains($file_path, '/CodeQuality/')) {
return;
}
// Skip InspectCommand.php - it documents what the checks do
if (str_contains($file_path, 'InspectCommand.php')) {
return;
}
// Get both original and sanitized content
$original_content = file_get_contents($file_path);
$original_lines = explode("\n", $original_content);
// Get sanitized content with comments and strings removed
$sanitized_data = FileSanitizer::sanitize_php($contents);
$sanitized_lines = $sanitized_data['lines'];
foreach ($sanitized_lines as $line_num => $sanitized_line) {
$line_number = $line_num + 1;
// Skip if the line is empty in sanitized version (was a comment)
if (trim($sanitized_line) === '') {
continue;
}
// Check for exec( usage - word boundary ensures we don't match "execute(" etc.
if (preg_match('/\bexec\s*\(/i', $sanitized_line)) {
$original_line = $original_lines[$line_num] ?? $sanitized_line;
$violation_message = "🚨 CRITICAL: exec() function detected - causes SILENT OUTPUT TRUNCATION
exec() has a fundamental flaw: it reads command output LINE-BY-LINE into an array, which:
- Hits memory/buffer limits on large outputs (>1MB typical)
- Silently truncates output without throwing errors or exceptions
- Causes catastrophic failures in compilation, bundling, and error reporting
- Makes debugging impossible (you see partial output with no indication of truncation)
Real-world example from this codebase:
- jqhtml compilation of 220-line template was truncated at row 4 (mid-line)
- No error thrown, no indication of failure
- Took hours to diagnose because the truncation was SILENT
- Fixed by replacing exec() with proc_open() - output jumped from 4KB to 35KB
This is why exec() is BANNED across the entire application.";
$resolution = "REQUIRED ACTION - Choose based on your needs:
QUICKEST FIX (Drop-in replacement - no refactoring needed):
Use \exec_safe() - RSpade framework helper with identical signature to exec():
\exec_safe(\$command, \$output, \$return_var);
Simply replace exec() with \exec_safe(). That's it. No other code changes needed.
Uses proc_open() internally to handle unlimited output without truncation.
Example:
// Before:
exec('git status 2>&1', \$output, \$code);
// After:
\exec_safe('git status 2>&1', \$output, \$code);
Benefits:
- Zero refactoring - maintains exact same signature as exec()
- All existing code continues to work identically
- No silent truncation (uses proc_open() internally)
- Framework helper function available everywhere
FOR ADVANCED USERS (Need full control):
Use proc_open() which streams unlimited output without size limits:
\$descriptors = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'] // stderr
];
\$process = proc_open(\$command, \$descriptors, \$pipes);
if (!is_resource(\$process)) {
throw new \\RuntimeException(\"Failed to execute command\");
}
fclose(\$pipes[0]); // Close stdin
\$output_str = stream_get_contents(\$pipes[1]); // Read all stdout
\$error_str = stream_get_contents(\$pipes[2]); // Read all stderr
fclose(\$pipes[1]);
fclose(\$pipes[2]);
\$return_code = proc_close(\$process);
// Combine stderr with stdout if command failed
if (\$return_code !== 0 && !empty(\$error_str)) {
\$output_str = \$error_str . \"\\n\" . \$output_str;
}
if (\$return_code !== 0) {
throw new \\RuntimeException(\"Command failed: {\$output_str}\");
}
Benefits:
- Streams unlimited output (no size limits)
- Separate stdout/stderr streams
- Proper return code validation
- No silent truncation
FOR SIMPLE CASES (Don't need return code):
Use shell_exec() for commands where you only need output:
\$output = shell_exec(\$command);
if (\$output === null) {
throw new \\RuntimeException(\"Command failed\");
}
Benefits:
- Simple one-line replacement
- Returns entire output as string (no line-by-line buffering)
- No silent truncation
- Drawback: Cannot get return code (assumes success if output is not null)
RATIONALE:
exec() was designed for simple command execution in the early PHP days. Modern PHP
applications with large compilation outputs, bundling systems, and complex toolchains
need proper stream handling. Both proc_open() and shell_exec() handle
unlimited output correctly - exec() does not.
NEVER use exec() for:
- Compilation outputs (esbuild, webpack, babel, jqhtml)
- Bundler commands
- Any command with potentially large output (>100 lines)
- Commands where you need to see complete error messages";
$this->add_violation(
$file_path,
$line_number,
$violation_message,
trim($original_line),
$resolution,
'critical'
);
}
}
}
}