$enabled, 'updated' => time(), 'generation' => bin2hex( random_bytes( 16 ) ) ) ); self::$version = null; self::$retryAfter = 0; } private static function socket(): string { if ( ! defined( 'ANTIVIRUS_AI_CLAMD_SOCKET' ) || ! is_string( ANTIVIRUS_AI_CLAMD_SOCKET ) || ! defined( 'ANTIVIRUS_AI_PRIVATE_STORAGE_VERIFIED' ) || true !== ANTIVIRUS_AI_PRIVATE_STORAGE_VERIFIED ) { throw new \RuntimeException( 'Ask the host to configure a private local ClamAV socket and its managed signature updates.' ); } $path = ANTIVIRUS_AI_CLAMD_SOCKET; if ( ! str_starts_with( $path, '/' ) || strlen( $path ) > 100 || str_contains( $path, "\0" ) || ! function_exists( 'posix_geteuid' ) ) { throw new \RuntimeException( 'The local scanner socket configuration is invalid.' ); } clearstatcache( true, $path ); $st = @lstat( $path ); if ( ! $st || 0140000 !== ( $st['mode'] & 0170000 ) || 0 !== ( $st['mode'] & 0007 ) || ! in_array( $st['uid'], array( 0, posix_geteuid() ), true ) ) { throw new \RuntimeException( 'The host must provide a protected local scanner socket.' ); } for ( $parent = dirname( $path ); '/' !== $parent; $parent = dirname( $parent ) ) { $stat = @lstat( $parent ); if ( ! $stat || 0040000 !== ( $stat['mode'] & 0170000 ) || 0 !== ( $stat['mode'] & 0022 ) || ! in_array( $stat['uid'], array( 0, posix_geteuid() ), true ) ) { throw new \RuntimeException( 'The local scanner socket has unsafe parent directories.' ); } } return $path; } /** Read-only status: never contacts the daemon just to render a page. */ public static function status(): array { $configured = false; try { self::socket(); $configured = true; } catch ( \Throwable $error ) {} $last = function_exists( 'get_option' ) ? get_option( 'antivirus_ai_clam_status', array() ) : array(); if ( ! is_array( $last ) ) { $last = array(); } return array( 'enabled' => self::enabled(), 'configured' => $configured, 'last_check' => (int) ( $last['at'] ?? 0 ), 'status' => ! self::enabled() ? 'disabled' : ( ! $configured ? 'unavailable' : ( (int) ( $last['at'] ?? 0 ) < time() - 3600 ? 'not_recently_checked' : ( $last['status'] ?? 'not_checked' ) ) ), 'database' => self::freshness( is_array( $last['database'] ?? null ) ? $last['database'] : array() ), 'limitation' => 'Optional host-managed general-purpose ClamAV engine. Only eligible files of up to 2 MiB are streamed to the local daemon. Its archive/decompression limits and detection coverage are controlled by the host; a clear result is not proof that a file is safe. No file contents leave the host through this integration.' ); } private static function freshness( array $database ): array { $age = time() - (int) ( $database['published'] ?? 0 ); $database['fresh'] = isset( $database['published'] ) && $age >= -300 && $age <= 172800; return $database; } public static function parseVersion( string $reply ): array { if ( ! preg_match( '~^ClamAV ([0-9]+\.[0-9]+\.[0-9]+(?:[-.a-zA-Z0-9]*)?)/([0-9]{1,10})/([A-Za-z]{3} [A-Za-z]{3} [ 0-9][0-9] [0-9]{2}:[0-9]{2}:[0-9]{2} [0-9]{4})$~D', $reply, $m ) ) { throw new \RuntimeException( 'The local scanner did not report a recognizable database version.' ); } $date = \DateTimeImmutable::createFromFormat( '!D M j H:i:s Y', preg_replace( '/ +/', ' ', $m[3] ), new \DateTimeZone( 'UTC' ) ); $errors = \DateTimeImmutable::getLastErrors(); if ( ! $date || ( is_array( $errors ) && ( $errors['warning_count'] || $errors['error_count'] ) ) ) { throw new \RuntimeException( 'The local scanner database timestamp is invalid.' ); } return self::freshness( array( 'engine' => $m[1], 'version' => $m[2], 'published' => $date->getTimestamp() ) ); } private static function write( $stream, string $bytes, float $deadline ): void { $offset = 0; while ( $offset < strlen( $bytes ) ) { if ( microtime( true ) >= $deadline ) { throw new \RuntimeException( 'Local scanner deadline reached.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- These are bounded local clamd socket protocol reads/writes, not filesystem publication. $n = @fwrite( $stream, substr( $bytes, $offset, 16384 ) ); if ( false === $n || 0 === $n ) { throw new \RuntimeException( 'Local scanner write failed.' ); } $offset += $n; } } private static function command( string $command, ?string $content = null ): string { $deadline = microtime( true ) + 3; $stream = @stream_socket_client( 'unix://' . self::socket(), $errno, $error, 0.5, STREAM_CLIENT_CONNECT ); if ( ! $stream ) { throw new \RuntimeException( 'The local scanner is unavailable.' ); } try { stream_set_timeout( $stream, 1 ); self::write( $stream, 'z' . $command . "\0", $deadline ); if ( null !== $content ) { for ( $offset = 0; $offset < strlen( $content ); $offset += 16384 ) { $part = substr( $content, $offset, 16384 ); self::write( $stream, pack( 'N', strlen( $part ) ) . $part, $deadline ); } self::write( $stream, pack( 'N', 0 ), $deadline ); } $reply = ''; while ( microtime( true ) < $deadline && strlen( $reply ) <= 1024 ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- These are bounded local clamd socket protocol reads/writes, not filesystem publication. $part = @fread( $stream, 1025 - strlen( $reply ) ); if ( false === $part || '' === $part ) { throw new \RuntimeException( 'Local scanner returned an incomplete response.' ); } $reply .= $part; if ( str_ends_with( $reply, "\0" ) && 1 === substr_count( $reply, "\0" ) ) { return substr( $reply, 0, -1 ); } } throw new \RuntimeException( 'Local scanner response exceeded its limit.' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- These are bounded local clamd socket protocol reads/writes, not filesystem publication. } finally { fclose( $stream ); } } public static function probe(): array { $result = array( 'status' => 'unavailable', 'database' => array(), 'at' => time() ); try { $result['database'] = self::parseVersion( self::command( 'VERSION' ) ); $result['status'] = $result['database']['fresh'] ? 'ready' : 'stale'; } catch ( \Throwable $error ) {} if ( function_exists( 'update_option' ) ) { update_option( 'antivirus_ai_clam_status', $result, false ); } self::$version = $result; if ( 'unavailable' === $result['status'] ) { self::$retryAfter = microtime( true ) + 60; } return $result; } public static function assess( string $content, array $context ): array { $out = array( 'status' => 'disabled', 'findings' => array(), 'database' => array() ); $preference = self::preference(); if ( true !== ( $preference['enabled'] ?? false ) ) { return $out; } if ( strlen( $content ) > self::MAX_BYTES ) { $out['status'] = 'limit'; return $out; } $version = self::$version; if ( ! $version || (int) $version['at'] < time() - 60 ) { $version = self::probe(); } $out['database'] = self::freshness( $version['database'] ); if ( microtime( true ) < self::$retryAfter || 'unavailable' === $version['status'] ) { $out['status'] = 'unavailable'; return $out; } // Stale definitions must not clear earlier engine evidence or claim a complete assessment. if ( ! $out['database']['fresh'] ) { $out['status'] = 'stale'; return $out; } try { $reply = self::command( 'INSTREAM', $content ); $current = self::preference(); if ( true !== ( $current['enabled'] ?? false ) || ( $current['generation'] ?? '' ) !== ( $preference['generation'] ?? '' ) ) { $out['status'] = 'disabled'; return $out; } if ( 'stream: OK' === $reply ) { $out['status'] = 'clear'; return $out; } if ( ! preg_match( '/^stream: ([A-Za-z0-9_.@!+() -]{1,180}) FOUND$/D', $reply, $m ) ) { throw new \RuntimeException( 'Local scanner did not finish the assessment.' ); } if ( str_starts_with( $m[1], 'Heuristics.Limits.' ) || str_starts_with( $m[1], 'Heuristics.Encrypted.' ) ) { $out['status'] = 'limit'; return $out; } $out['status'] = 'matched'; $out['findings'][] = Finding::make( $context + array( 'category' => 'suspicious_behavior', 'detector_id' => 'engine.clamav', 'detector_version' => $out['database']['engine'], 'severity' => 'high', 'confidence' => 'strong', 'reason' => 'The configured local ClamAV engine reported a signature or heuristic match.', 'limitations' => array( 'The daemon may use official or host-added rules. This match does not establish malicious intent; review the signature and file before acting.', 'General-purpose engine coverage and host archive limits are separate from Shieldlume Security structural analysis.' ), 'recommended_action' => 'Review the reported signature with the host and compare with a trusted replacement. Do not automatically delete the file.', 'evidence' => array( array( 'type' => 'engine_signature', 'value' => $m[1] ), array( 'type' => 'database_version', 'value' => $out['database']['version'] ) ), 'source' => array( 'provider' => 'Local ClamAV daemon', 'provenance' => 'Host-configured Unix socket; scanned bytes only, no path command.' ), 'rule_freshness' => array( 'version' => $out['database']['version'], 'published_at' => gmdate( 'c', $out['database']['published'] ) ) ) ); } catch ( \Throwable $error ) { $out['status'] = 'unavailable'; self::$retryAfter = microtime( true ) + 60; self::$version = array( 'status' => 'unavailable', 'database' => $out['database'], 'at' => time() ); if ( function_exists( 'update_option' ) ) { update_option( 'antivirus_ai_clam_status', self::$version, false ); } } return $out; } }