<?php
/**
 * Contextual PHP-in-uploads detection.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Detection\File;

use LocalGuardScanner\Domain\Detection\Evidence;
use LocalGuardScanner\Domain\Detection\MalwareHeuristicDetector;
use LocalGuardScanner\Domain\Detection\ScannableObject;

/**
 * Distinguishes inert PHP files from executable upload-area PHP.
 */
final class UploadPhpDetector implements MalwareHeuristicDetector {
	const MAX_INERT_BYTES = 4096;

	/** @return string */
	public function id() {
		return 'file.upload_php';
	}

	/** @inheritDoc */
	public function inspect( ScannableObject $scanned_object ) {
		if ( 'file' !== $scanned_object->type() ) {
			return array();
		}
		$locator = strtolower( str_replace( '\\', '/', $scanned_object->locator() ) );
		if ( false === strpos( '/' . $locator, '/wp-content/uploads/' ) || ! preg_match( '/\.(?:php\d*|phtml|phar)$/i', $locator ) ) {
			return array();
		}
		$attributes = $scanned_object->attributes();
		$content    = isset( $attributes['content'] ) && is_string( $attributes['content'] ) ? $attributes['content'] : null;
		$size       = isset( $attributes['size'] ) ? (int) $attributes['size'] : ( is_string( $content ) ? strlen( $content ) : self::MAX_INERT_BYTES + 1 );
		if ( is_string( $content ) && $size <= self::MAX_INERT_BYTES && $this->is_inert( $content ) ) {
			return array( new Evidence( 'file.safe_upload_index', 5, 98, 'safe_upload_index_marker', 'token-verified-inert-upload-php' ) );
		}
		return array( new Evidence( 'file.php_in_uploads.v2', 55, 85, 'php_file_in_uploads', basename( $locator ) ) );
	}

	/** @return bool */
	private function is_inert( string $content ): bool {
		$significant = array();
		foreach ( token_get_all( $content ) as $token ) {
			if ( is_array( $token ) ) {
				if ( in_array( $token[0], array( T_OPEN_TAG, T_CLOSE_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
					continue;
				}
				if ( T_INLINE_HTML === $token[0] && '' === trim( $token[1] ) ) {
					continue;
				}
				$significant[] = T_EXIT === $token[0] ? 'exit' : 'other';
				continue;
			}
			if ( '' !== trim( $token ) ) {
				$significant[] = $token;
			}
		}
		return empty( $significant ) || in_array(
			$significant,
			array(
				array( 'exit' ),
				array( 'exit', ';' ),
				array( 'exit', '(', ')' ),
				array( 'exit', '(', ')', ';' ),
			),
			true
		);
	}
}
