Files
rspade_system/app/RSpade/CodeQuality/Rules/PHP/StreamBlockingMode_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

258 lines
8.9 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 StreamBlockingMode_CodeQualityRule extends CodeQualityRule_Abstract
{
public function get_id(): string
{
return 'PHP-STREAM-01';
}
public function get_name(): string
{
return 'Stream Blocking Mode Required';
}
public function get_description(): string
{
return 'Streams from proc_open() and network sockets must set blocking mode before reading to prevent data truncation';
}
public function get_file_patterns(): array
{
return ['*.php'];
}
public function get_default_severity(): string
{
return 'high';
}
/**
* Check PHP file for stream operations without explicit blocking mode
* and dangerous read patterns that truncate data
*
* PHP streams from proc_open(), fsockopen(), popen(), and stream_socket_client()
* default to non-blocking mode in some contexts, which causes incomplete reads
* and silent data truncation.
*/
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;
}
// Get sanitized content with comments and strings removed
$sanitized_data = FileSanitizer::sanitize_php($contents);
$sanitized_code = $sanitized_data['content'];
// Check for stream sources
$stream_sources_pattern = '/\b(proc_open|fsockopen|stream_socket_client|popen)\s*\(/i';
if (!preg_match($stream_sources_pattern, $sanitized_code)) {
return; // No stream sources, skip
}
// Check for stream read operations
$stream_reads_pattern = '/\b(fread|fgets|stream_get_contents|stream_copy_to_stream)\s*\(/i';
if (!preg_match($stream_reads_pattern, $sanitized_code)) {
return; // No stream reads, skip
}
$original_lines = explode("\n", file_get_contents($file_path));
$sanitized_lines = $sanitized_data['lines'];
// VIOLATION 1: Missing stream_set_blocking()
if (!preg_match('/\bstream_set_blocking\s*\(/i', $sanitized_code)) {
foreach ($sanitized_lines as $line_num => $sanitized_line) {
if (preg_match($stream_sources_pattern, $sanitized_line)) {
$this->add_violation(
$file_path,
$line_num + 1,
$this->get_missing_blocking_message(),
trim($original_lines[$line_num] ?? $sanitized_line),
$this->get_missing_blocking_resolution(),
'high'
);
return; // Only report first occurrence
}
}
return;
}
// VIOLATION 2: Dangerous read pattern - breaking on false in fread loop
// Pattern: while (!feof($stream)) { $chunk = fread(...); if ($chunk === false) { break; } ... }
// This breaks prematurely and truncates data because fread can return false temporarily
if (preg_match('/while\s*\([^)]*!feof[^)]*\)[^{]*\{[^}]*fread[^}]*===\s*false[^}]*break/is', $sanitized_code)) {
foreach ($sanitized_lines as $line_num => $sanitized_line) {
if (preg_match('/if\s*\([^)]*===\s*false[^)]*\)\s*\{[^}]*break/', $sanitized_line)) {
$this->add_violation(
$file_path,
$line_num + 1,
$this->get_dangerous_break_message(),
trim($original_lines[$line_num] ?? $sanitized_line),
$this->get_dangerous_break_resolution(),
'critical'
);
return;
}
}
}
// VIOLATION 3: Using stream_get_contents() in blocking mode without checking for false
// stream_get_contents() has 8192 byte default buffer and will truncate silently
if (preg_match('/stream_get_contents\s*\([^)]+\)\s*;/i', $sanitized_code) &&
preg_match('/stream_set_blocking\s*\([^)]+,\s*true\s*\)/i', $sanitized_code)) {
foreach ($sanitized_lines as $line_num => $sanitized_line) {
if (preg_match('/stream_get_contents\s*\(/i', $sanitized_line)) {
// Check if it's NOT in a proper loop
$context_start = max(0, $line_num - 3);
$context_end = min(count($sanitized_lines) - 1, $line_num + 3);
$context = implode("\n", array_slice($sanitized_lines, $context_start, $context_end - $context_start + 1));
// If not in a while loop that handles chunks, flag it
if (!preg_match('/while\s*\([^)]*!feof|while\s*\([^)]*!\$.*_done/', $context)) {
$this->add_violation(
$file_path,
$line_num + 1,
$this->get_unsafe_stream_get_contents_message(),
trim($original_lines[$line_num] ?? $sanitized_line),
$this->get_unsafe_stream_get_contents_resolution(),
'high'
);
return;
}
}
}
}
}
private function get_missing_blocking_message(): string
{
return "Stream operations without explicit blocking mode
File uses stream sources (proc_open/fsockopen/popen/stream_socket_client) with
read operations but does not call stream_set_blocking().
PHP streams default to non-blocking mode in some contexts, which causes:
- Incomplete reads with partial data
- Silent data truncation (no errors or warnings)
- Race conditions depending on when data arrives
- Data integrity issues for command output and file transfers";
}
private function get_missing_blocking_resolution(): string
{
return "REQUIRED ACTION:
Add stream_set_blocking(\$stream, true) before reading AND use proper read loop:
CORRECT PATTERN:
\$process = proc_open(\$command, \$descriptors, \$pipes);
fclose(\$pipes[0]);
stream_set_blocking(\$pipes[1], true);
stream_set_blocking(\$pipes[2], true);
// Read stdout until EOF
\$output = '';
while (!feof(\$pipes[1])) {
\$chunk = fread(\$pipes[1], 8192);
if (\$chunk !== false) {
\$output .= \$chunk;
}
}
// Read stderr until EOF
\$error = '';
while (!feof(\$pipes[2])) {
\$chunk = fread(\$pipes[2], 8192);
if (\$chunk !== false) {
\$error .= \$chunk;
}
}";
}
private function get_dangerous_break_message(): string
{
return "CRITICAL: Dangerous stream read pattern will truncate data
This fread() loop breaks on false, which causes data truncation because fread()
can return false TEMPORARILY even when more data is available.
Bug impact:
- Large outputs silently truncated at ~8KB
- No error thrown, appears successful
- Production data loss and corruption
- Intermittent failures depending on timing";
}
private function get_dangerous_break_resolution(): string
{
return "REQUIRED FIX:
Use feof() as the loop condition - do NOT check it inside the loop.
WRONG PATTERN (race condition, will truncate):
\$done = false;
while (!\$done) {
\$chunk = fread(\$stream, 8192);
if (\$chunk === '') {
if (feof(\$stream)) { // ❌ Checked AFTER empty read
\$done = true;
}
} else {
\$output .= \$chunk;
}
}
CORRECT PATTERN:
\$output = '';
while (!feof(\$stream)) {
\$chunk = fread(\$stream, 8192);
if (\$chunk !== false) {
\$output .= \$chunk;
}
}";
}
private function get_unsafe_stream_get_contents_message(): string
{
return "Unsafe stream_get_contents() usage may truncate large outputs
stream_get_contents() uses 8192 byte default buffer. For large outputs (>8KB),
data will be silently truncated.
Even with stream_set_blocking(true), stream_get_contents() may not read all data
in one call for large outputs.";
}
private function get_unsafe_stream_get_contents_resolution(): string
{
return "Use chunked read loop instead of stream_get_contents():
UNSAFE:
stream_set_blocking(\$pipe, true);
\$output = stream_get_contents(\$pipe); // ❌ May truncate >8KB
SAFE:
stream_set_blocking(\$pipe, true);
\$output = '';
while (!feof(\$pipe)) {
\$chunk = fread(\$pipe, 8192);
if (\$chunk !== false) {
\$output .= \$chunk;
}
}";
}
}