#!/usr/bin/env php
<?php
Phar::mapPhar( "ishacked.phar" );
require "phar://ishacked.phar/ishacked.php";
__HALT_COMPILER(); ?>
Â            ishacked.phar       ishacked.phpØ  ŒÖjØ  DàÅ¤         VERSION   ŒÖj   ¨žŽ¤      $   src/class-ishacked-cli-companion.php(G  ŒÖj(G  @ö·Ë¤      #   src/class-ishacked-cli-database.php¥!  ŒÖj¥!  �è¤         src/class-ishacked-cli-fs.phpÔ  ŒÖjÔ  Ož²¤      #   src/class-ishacked-cli-installs.phpX  ŒÖjX  µÑ§Z¤      "   src/class-ishacked-cli-lookups.php(  ŒÖj(  â`@¤      !   src/class-ishacked-cli-output.phpõ  ŒÖjõ  »…Í¤         src/class-ishacked-cli-pool.php  ŒÖj  XËÿ(¤         src/class-ishacked-cli-scan.phpR  ŒÖjR  H„åÚ¤      !   src/class-ishacked-cli-server.phpð+  ŒÖjð+  õÒ¶ì¤         src/class-ishacked-cli.php2e  ŒÖj2e  qÑ§¤      !   includes/class-ishacked-rules.phpÑ  ŒÖjÑ  dhœ¤      #   includes/class-ishacked-compare.php�  ŒÖj�  _ÇØ�¤      &   includes/class-ishacked-advisories.phpë%  ŒÖjë%  Ó� j¤      #   includes/class-ishacked-headers.php„  ŒÖj„  ’”iü¤      <?php
/**
 * The ishacked command, IsHacked's server companion. Scans the WordPress installs on a server for malware
 * signs, files that differ from the official releases and known vulnerabilities, without loading
 * WordPress. Runs from the command line only; `php ishacked.php help` (or `ishacked.phar help`).
 *
 * @package IsHacked
 */

if ( 'cli' !== PHP_SAPI ) {
	exit( 2 );
}
if ( PHP_VERSION_ID < 70400 ) {
	fwrite( STDERR, 'ishacked needs PHP 7.4 or later; this is PHP ' . PHP_VERSION . ".\n" );
	exit( 2 );
}
foreach ( array( 'json', 'pcre', 'hash' ) as $ishacked_ext ) {
	if ( ! extension_loaded( $ishacked_ext ) ) {
		fwrite( STDERR, "ishacked needs PHP's $ishacked_ext extension.\n" );
		exit( 2 );
	}
}

// The plugin's shared classes stop when opened from a browser unless ABSPATH is defined. WordPress
// itself is never loaded here; this only lets them load.
if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/' );
}

// Inside the phar the shared classes sit in includes/; in the repository, in the plugin's folder.
$ishacked_shared = is_dir( __DIR__ . '/includes' ) ? __DIR__ . '/includes' : dirname( __DIR__ ) . '/ishacked/includes';
foreach ( array( 'rules', 'compare', 'advisories', 'headers' ) as $ishacked_name ) {
	require_once $ishacked_shared . '/class-ishacked-' . $ishacked_name . '.php';
}
// Listed rather than globbed: glob() can't read inside a phar.
foreach ( array( '', '-fs', '-installs', '-lookups', '-scan', '-pool', '-output', '-database', '-server', '-companion' ) as $ishacked_name ) {
	require_once __DIR__ . '/src/class-ishacked-cli' . $ishacked_name . '.php';
}
define( 'ISHACKED_CLI_VERSION', trim( (string) file_get_contents( __DIR__ . '/VERSION' ) ) );

exit( IsHacked_Cli::main( $argv ) );
0.1.0
<?php
/**
 * The connected mode: `connect` links this server to an IsHacked account with a one-time code from
 * the dashboard's Servers page, `report` (from cron) sends a signed report, `disconnect` asks the
 * dashboard to forget this server. The server's private key never leaves the config file (0600);
 * the dashboard keeps only the public half, so it can't send anything here or act for this server.
 *
 * @package IsHacked
 */

/**
 * Server companion connection and reports.
 */
final class IsHacked_Cli_Companion {

	const API = 'https://ishacked.com';

	/** Prefix of every signed message (src/server-companion/routes.ts). */
	const PREFIX = 'ishacked-companion-v1';

	/** Code format, as the dashboard creates them. */
	const CODE_RE = '/^IHS-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/';

	/** Most installs in one report. */
	const MAX_INSTALLS = 500;

	/**
	 * The config file: --config, else $XDG_CONFIG_HOME/ishacked/companion.json or
	 * ~/.config/ishacked/companion.json.
	 *
	 * @param string $given --config value.
	 * @return string
	 */
	public static function config_path( $given ) {
		if ( '' !== $given ) {
			return $given;
		}
		$xdg  = getenv( 'XDG_CONFIG_HOME' );
		$home = getenv( 'HOME' );
		return ( $xdg ? $xdg : $home . '/.config' ) . '/ishacked/companion.json';
	}

	/**
	 * The saved connection, or null.
	 *
	 * @param string $path Config file.
	 * @return array|null
	 */
	public static function load( $path ) {
		$text = IsHacked_Cli_Fs::head( $path, 65536 );
		$c    = null === $text ? null : json_decode( $text, true );
		return is_array( $c ) && isset( $c['server_id'], $c['secret_key'], $c['api'], $c['paths'] ) ? $c : null;
	}

	/**
	 * Saves the connection, readable by this user only.
	 *
	 * @param string $path Config file.
	 * @param array  $c    Connection.
	 * @throws InvalidArgumentException When it can't be written.
	 */
	private static function save( $path, array $c ) {
		$dir = dirname( $path );
		if ( ! is_dir( $dir ) && ! @mkdir( $dir, 0700, true ) ) {
			throw new InvalidArgumentException( "Cannot create $dir" );
		}
		$old = umask( 0077 );
		$tmp = $path . '.' . getmypid() . '.tmp';
		$ok  = false !== @file_put_contents( $tmp, json_encode( $c, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n" ) && @chmod( $tmp, 0600 ) && @rename( $tmp, $path );
		umask( $old );
		if ( ! $ok ) {
			@unlink( $tmp );
			throw new InvalidArgumentException( "Cannot write $path" );
		}
	}

	/**
	 * `ishacked connect CODE [folders...]`.
	 *
	 * @param array $opt Options: code, paths, name, yes, api, config.
	 * @return int Exit code.
	 * @throws InvalidArgumentException On a bad code, a refusal or a failed request.
	 */
	public static function connect( array $opt ) {
		self::need_sodium();
		$code = strtoupper( trim( $opt['code'] ) );
		if ( ! preg_match( self::CODE_RE, $code ) ) {
			throw new InvalidArgumentException( 'A connection code looks like IHS-XXXX-XXXX-XXXX. Create one on the Servers page at ishacked.com.' );
		}
		$path = self::config_path( $opt['config'] );
		if ( self::load( $path ) && ! $opt['yes'] ) {
			throw new InvalidArgumentException( "This server is already connected ($path). Run \"ishacked disconnect\" first, or add --yes to replace the connection." );
		}
		$api     = self::api_base( $opt['api'] );
		$preview = self::request( $api, 'POST', '/server/v1/connect/preview', array( 'code' => $code ) );
		$name    = '' !== $opt['name'] ? $opt['name'] : (string) gethostname();
		fwrite( STDERR, 'This server ("' . IsHacked_Cli_Output::clean( $name ) . '") will report to the IsHacked account ' . IsHacked_Cli_Output::clean( $preview['account'] ) . ".\n" );
		if ( ! $opt['yes'] ) {
			if ( ! function_exists( 'stream_isatty' ) || ! stream_isatty( STDIN ) ) {
				throw new InvalidArgumentException( 'Add --yes to connect without a question.' );
			}
			fwrite( STDERR, 'Continue only if you recognize that account. Connect? [y/N] ' );
			if ( ! preg_match( '/^y(?:es)?$/i', trim( (string) fgets( STDIN ) ) ) ) {
				throw new InvalidArgumentException( 'Not connected.' );
			}
		}
		$pair   = sodium_crypto_sign_keypair();
		$public = bin2hex( sodium_crypto_sign_publickey( $pair ) );
		$r      = self::request(
			$api,
			'POST',
			'/server/v1/connect',
			array(
				'code'       => $code,
				'public_key' => $public,
				'name'       => substr( $name, 0, 100 ),
				'hostname'   => substr( (string) gethostname(), 0, 255 ),
				'version'    => ISHACKED_CLI_VERSION,
			)
		);
		self::save(
			$path,
			array(
				'api'        => $api,
				'server_id'  => (string) $r['server_id'],
				'account'    => (string) $r['account'],
				'name'       => $name,
				'public_key' => $public,
				'secret_key' => bin2hex( sodium_crypto_sign_secretkey( $pair ) ),
				'paths'      => $opt['paths'],
				'connected'  => gmdate( 'c' ),
			)
		);
		$phar = class_exists( 'Phar' ) && '' !== Phar::running( false ) ? Phar::running( false ) : IsHacked_Cli::$script;
		$cron = sprintf( '%d * * * * %s %s report --quiet', random_int( 0, 59 ), PHP_BINARY, escapeshellarg( (string) $phar ) );
		fwrite( STDOUT, 'Connected to ' . IsHacked_Cli_Output::clean( $r['account'] ) . '. Folders: ' . IsHacked_Cli_Output::clean( implode( ', ', $opt['paths'] ) ) . "\n\nAdd this line to the crontab (crontab -e) to report every hour:\n\n  $cron\n\nOr run \"ishacked report\" now.\n" );
		return IsHacked_Cli::EXIT_CLEAN;
	}

	/**
	 * `ishacked report [folders...]`: scans and sends a signed report.
	 *
	 * @param array $opt Options from IsHacked_Cli, plus config.
	 * @return int Exit code.
	 * @throws InvalidArgumentException When not connected or the report is refused.
	 */
	public static function report( array $opt ) {
		self::need_sodium();
		$path = self::config_path( $opt['config'] );
		$c    = self::load( $path );
		if ( ! $c ) {
			throw new InvalidArgumentException( 'Not connected. Run "ishacked connect CODE" with a code from the Servers page at ishacked.com.' );
		}
		$lock = @fopen( $path . '.lock', 'c' );
		if ( ! $lock || ! flock( $lock, LOCK_EX | LOCK_NB ) ) {
			throw new InvalidArgumentException( 'Another report is running.' );
		}
		$paths  = $opt['paths_given'] ? $opt['paths'] : $c['paths'];
		$report = self::build( $paths, $opt );
		// Sent in parts of at most 8 MB, so a server with many installs stays within the dashboard's
		// limits; the server facts go with the first part.
		$r = array( 'installs' => array() );
		foreach ( self::parts( $report ) as $part ) {
			$answer        = self::signed( $c, '/server/v1/report', gzencode( $part, 6 ), true );
			$r['installs'] = array_merge( $r['installs'], isset( $answer['installs'] ) && is_array( $answer['installs'] ) ? $answer['installs'] : array() );
		}
		flock( $lock, LOCK_UN );
		fclose( $lock );
		if ( ! $opt['quiet'] ) {
			$matched = array();
			$other   = array();
			foreach ( isset( $r['installs'] ) && is_array( $r['installs'] ) ? $r['installs'] : array() as $i ) {
				if ( ! empty( $i['site'] ) ) {
					$matched[] = IsHacked_Cli_Output::clean( $i['path'] . ' â†’ ' . $i['site'] );
				} else {
					$other[] = IsHacked_Cli_Output::clean( $i['path'] );
				}
			}
			fwrite( STDOUT, sprintf( "Report sent: %d installs.\n", count( $report['installs'] ) ) );
			foreach ( $matched as $line ) {
				fwrite( STDOUT, "  $line\n" );
			}
			if ( $other ) {
				fwrite( STDOUT, "Not matched to a site in the account (assign them on the Servers page):\n  " . implode( "\n  ", $other ) . "\n" );
			}
		}
		return IsHacked_Cli::EXIT_CLEAN;
	}

	/**
	 * The report: every install's files, components and database facts, and the server facts.
	 *
	 * @param string[] $paths Folders.
	 * @param array    $opt   Scan options.
	 * @return array
	 */
	public static function build( array $paths, array $opt ) {
		$opt['paths'] = $paths;
		$plan         = IsHacked_Cli::plan( $opt );
		$installs     = array_slice( array_keys( $plan['installs'] ), 0, self::MAX_INSTALLS );
		$out          = array(
			'version'   => 1,
			'checkedAt' => gmdate( 'Y-m-d\TH:i:s\Z' ),
			'companion' => array(
				'version' => ISHACKED_CLI_VERSION,
				'php'     => PHP_VERSION,
			),
			'server'    => IsHacked_Cli_Server::facts( $installs ),
			'installs'  => array(),
		);
		$invs         = array();
		foreach ( $installs as $path ) {
			$invs[ $path ] = IsHacked_Cli_Installs::inventory( $path );
		}
		$lookups  = new IsHacked_Cli_Lookups( $opt['cache_dir'], $opt['offline'], IsHacked_Cli::$transport );
		$official = IsHacked_Cli_Scan::official( $lookups, $invs );
		$results  = IsHacked_Cli_Pool::run(
			$installs,
			$opt['workers'],
			function ( $path ) use ( $opt, $installs, $invs, $official ) {
				$prune = array();
				foreach ( $installs as $other ) {
					if ( 0 === strpos( $other, $path . '/' ) ) {
						$prune[ $other ] = true;
					}
				}
				$scan = IsHacked_Cli_Scan::install(
					$invs[ $path ],
					$official,
					array(
						'prune'          => $prune,
						'exclude'        => $opt['exclude'],
						'all_files'      => false,
						'max_scan_bytes' => $opt['max_scan_bytes'],
						'report'         => true,
					)
				);
				return array( $invs[ $path ], $scan, IsHacked_Cli_Database::read( $path ) );
			},
			function () {}
		);
		foreach ( $results as $res ) {
			list( $inv, $scan, $db ) = $res;
			$plugins                 = array();
			foreach ( $inv['plugins'] as $slug => $p ) {
				$plugins[] = array(
					'slug'    => (string) $slug,
					'file'    => $p['file'],
					'version' => $p['version'],
				);
			}
			$themes = array();
			foreach ( $inv['themes'] as $slug => $t ) {
				$themes[] = array(
					'slug'    => (string) $slug,
					'version' => $t['version'],
				);
			}
			$out['installs'][] = array(
				'path'      => $inv['path'],
				'homeUrl'   => $db['home'],
				'wpVersion' => $inv['version'],
				'locale'    => $inv['locale'],
				'multisite' => $inv['multisite'],
				'plugins'   => $plugins,
				'themes'    => $themes,
				'muPlugins' => $inv['mu'],
				'dropins'   => $inv['dropins'],
				'coverage'  => array(
					'filesComplete'   => $scan['coverage']['complete'],
					'files'           => $scan['coverage']['files'],
					'coreVerified'    => ! empty( $scan['coverage']['coreVerified'] ),
					'missingCore'     => array_slice( isset( $scan['coverage']['missing'] ) ? $scan['coverage']['missing'] : array(), 0, 500 ),
					'pluginsVerified' => $scan['coverage']['verified'],
					'unverified'      => $scan['coverage']['unverified'],
					'notes'           => array_slice( array_merge( $scan['coverage']['notes'], '' !== $db['note'] ? array( $db['note'] ) : array() ), 0, 20 ),
				),
				'files'     => $scan['files'],
				'database'  => $db['facts'],
			);
		}
		return $out;
	}

	/**
	 * The report as JSON parts of at most 8 MB each, installs split between them.
	 *
	 * @param array $report From build().
	 * @return string[]
	 */
	public static function parts( array $report ) {
		$flags            = JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE;
		$parts            = array();
		$part             = $report;
		$part['installs'] = array();
		$size             = strlen( (string) json_encode( $part, $flags ) );
		foreach ( $report['installs'] as $install ) {
			$one = strlen( (string) json_encode( $install, $flags ) ) + 1;
			if ( $part['installs'] && $size + $one > 8000000 ) {
				$parts[]          = (string) json_encode( $part, $flags );
				$part['server']   = null;
				$part['installs'] = array();
				$size             = strlen( (string) json_encode( $part, $flags ) );
			}
			$part['installs'][] = $install;
			$size              += $one;
		}
		$parts[] = (string) json_encode( $part, $flags );
		return $parts;
	}

	/**
	 * `ishacked disconnect`: asks the dashboard to forget this server, then deletes the key.
	 *
	 * @param array $opt Options: config.
	 * @return int Exit code.
	 * @throws InvalidArgumentException When not connected.
	 */
	public static function disconnect( array $opt ) {
		$path = self::config_path( $opt['config'] );
		$c    = self::load( $path );
		if ( ! $c ) {
			throw new InvalidArgumentException( 'Not connected.' );
		}
		try {
			self::need_sodium();
			self::signed( $c, '/server/v1/disconnect', '{}', false );
		} catch ( InvalidArgumentException $e ) {
			fwrite( STDERR, 'The dashboard was not told (' . $e->getMessage() . "); revoke this server on the Servers page.\n" );
		}
		@unlink( $path );
		fwrite( STDOUT, "Disconnected; the key was deleted.\n" );
		return IsHacked_Cli::EXIT_CLEAN;
	}

	/**
	 * `ishacked status`.
	 *
	 * @param array $opt Options: config.
	 * @return int Exit code.
	 */
	public static function status( array $opt ) {
		$path = self::config_path( $opt['config'] );
		$c    = self::load( $path );
		if ( ! $c ) {
			fwrite( STDOUT, "Not connected (no $path).\n" );
			return IsHacked_Cli::EXIT_CLEAN;
		}
		fwrite( STDOUT, IsHacked_Cli_Output::clean( 'Connected to ' . $c['account'] . ' as "' . $c['name'] . '" since ' . $c['connected'] . ' (' . $c['api'] . ').' ) . "\n" . IsHacked_Cli_Output::clean( 'Folders: ' . implode( ', ', $c['paths'] ) ) . "\nConfig: $path\n" );
		return IsHacked_Cli::EXIT_CLEAN;
	}

	/**
	 * The dashboard's address: https://ishacked.com, or --api / ISHACKED_API_BASE for development
	 * (https anywhere, http only to this machine).
	 *
	 * @param string $given --api value.
	 * @return string
	 * @throws InvalidArgumentException On an address that isn't allowed.
	 */
	private static function api_base( $given ) {
		$env  = getenv( 'ISHACKED_API_BASE' );
		$base = rtrim( '' !== $given ? $given : ( $env ? $env : self::API ), '/' );
		$p    = parse_url( $base );
		$ok   = is_array( $p ) && isset( $p['scheme'], $p['host'] ) && ! isset( $p['path'] ) && ! isset( $p['user'] )
			&& ( 'https' === $p['scheme'] || ( 'http' === $p['scheme'] && in_array( $p['host'], array( '127.0.0.1', 'localhost', '[::1]' ), true ) ) );
		if ( ! $ok ) {
			throw new InvalidArgumentException( 'The dashboard address must be https:// (http:// only to this machine), with no path.' );
		}
		return $base;
	}

	/**
	 * A signed request: the server's key over method, path, server id, time, nonce and the body's hash.
	 *
	 * @param array  $c    Connection.
	 * @param string $path Path.
	 * @param string $body Body as sent.
	 * @param bool   $gzip Whether the body is gzip-compressed JSON.
	 * @return array Response.
	 */
	private static function signed( array $c, $path, $body, $gzip ) {
		$time    = (string) (int) floor( microtime( true ) * 1000 );
		$nonce   = bin2hex( random_bytes( 16 ) );
		$hash    = hash( 'sha256', $body );
		$message = implode( "\n", array( self::PREFIX, 'POST', $path, $c['server_id'], $time, $nonce, $hash ) );
		$sig     = bin2hex( sodium_crypto_sign_detached( $message, hex2bin( $c['secret_key'] ) ) );
		$headers = array(
			'X-IsHacked-Server: ' . $c['server_id'],
			'X-IsHacked-Time: ' . $time,
			'X-IsHacked-Nonce: ' . $nonce,
			'X-IsHacked-Content-SHA256: ' . $hash,
			'X-IsHacked-Signature: ' . $sig,
		);
		if ( $gzip ) {
			$headers[] = 'Content-Encoding: gzip';
		}
		return self::send( $c['api'] . $path, $body, $headers );
	}

	/**
	 * A JSON request.
	 *
	 * @param string $api    Base.
	 * @param string $method Method.
	 * @param string $path   Path.
	 * @param array  $data   Body.
	 * @return array Response.
	 */
	private static function request( $api, $method, $path, array $data ) {
		return self::send( $api . $path, (string) json_encode( $data ), array() );
	}

	/**
	 * POSTs and decodes the JSON answer; anything but 2xx throws with the dashboard's error.
	 *
	 * @param string   $url     URL.
	 * @param string   $body    Body.
	 * @param string[] $headers Extra headers.
	 * @return array
	 * @throws InvalidArgumentException On failure.
	 */
	private static function send( $url, $body, array $headers ) {
		$headers = array_merge( array( 'Content-Type: application/json', 'User-Agent: ishacked-cli/' . ISHACKED_CLI_VERSION ), $headers );
		$status  = 0;
		$text    = false;
		if ( function_exists( 'curl_init' ) ) {
			$ch = curl_init( $url );
			curl_setopt_array(
				$ch,
				array(
					CURLOPT_POST           => true,
					CURLOPT_POSTFIELDS     => $body,
					CURLOPT_HTTPHEADER     => $headers,
					CURLOPT_RETURNTRANSFER => true,
					CURLOPT_FOLLOWLOCATION => false,
					CURLOPT_PROTOCOLS      => CURLPROTO_HTTPS | CURLPROTO_HTTP,
					CURLOPT_SSL_VERIFYPEER => true,
					CURLOPT_SSL_VERIFYHOST => 2,
					CURLOPT_CONNECTTIMEOUT => 10,
					CURLOPT_TIMEOUT        => 120,
				)
			);
			$text   = curl_exec( $ch );
			$status = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
			$error  = curl_error( $ch );
		} else {
			$ctx = stream_context_create(
				array(
					'http' => array(
						'method'          => 'POST',
						'header'          => implode( "\r\n", $headers ),
						'content'         => $body,
						'timeout'         => 120,
						'follow_location' => 0,
						'ignore_errors'   => true,
					),
				)
			);
			$h   = @fopen( $url, 'rb', false, $ctx );
			if ( $h ) {
				$meta = stream_get_meta_data( $h );
				$text = stream_get_contents( $h, 1048576 );
				fclose( $h );
				foreach ( isset( $meta['wrapper_data'] ) ? (array) $meta['wrapper_data'] : array() as $line ) {
					if ( preg_match( '#^HTTP/\S+\s+(\d{3})#', (string) $line, $m ) ) {
						$status = (int) $m[1];
					}
				}
			}
			$error = 'no answer';
		}
		if ( false === $text || 0 === $status ) {
			throw new InvalidArgumentException( 'Could not reach ' . parse_url( $url, PHP_URL_HOST ) . ': ' . $error );
		}
		$data = json_decode( (string) $text, true );
		if ( $status < 200 || $status >= 300 ) {
			$msg = is_array( $data ) && isset( $data['error'] ) && is_string( $data['error'] ) ? $data['error'] : 'HTTP ' . $status;
			throw new InvalidArgumentException( IsHacked_Cli_Output::clean( substr( $msg, 0, 300 ) ) );
		}
		return is_array( $data ) ? $data : array();
	}

	/**
	 * Signing needs PHP's sodium extension (bundled with PHP since 7.2).
	 *
	 * @throws InvalidArgumentException Without it.
	 */
	private static function need_sodium() {
		if ( ! function_exists( 'sodium_crypto_sign_detached' ) ) {
			throw new InvalidArgumentException( 'Connecting needs PHP\'s sodium extension, which this PHP lacks. Scanning works without it.' );
		}
	}
}
<?php
/**
 * Reads an install's database without loading WordPress: the connection details come from
 * wp-config.php, read with PHP's tokenizer (the file is never run), and only literal values are used.
 * Queries run in a read-only transaction. Password hashes, application password values and post
 * content never leave this class; only IDs, logins and SHA-256 fingerprints do.
 *
 * @package IsHacked
 */

/**
 * Database facts.
 */
final class IsHacked_Cli_Database {

	/** Most administrators and posts listed. */
	const MAX_ROWS = 1000;

	/**
	 * Literal values from wp-config.php: DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, WP_HOME, MULTISITE and
	 * $table_prefix. A value built from code (getenv(), a concatenation) is left out.
	 *
	 * @param string $text wp-config.php content.
	 * @return array<string, string|bool>
	 */
	public static function config( $text ) {
		$out    = array();
		$tokens = array_values(
			array_filter(
				token_get_all( $text ),
				function ( $t ) {
					return ! is_array( $t ) || ! in_array( $t[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true );
				}
			)
		);
		$wanted = array( 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST', 'WP_HOME', 'WP_SITEURL', 'MULTISITE' );
		$count  = count( $tokens );
		for ( $i = 0; $i < $count; ++$i ) {
			$t = $tokens[ $i ];
			// A define() call with a plain value.
			if ( is_array( $t ) && T_STRING === $t[0] && 'define' === strtolower( $t[1] ) && isset( $tokens[ $i + 5 ] ) && '(' === $tokens[ $i + 1 ] ) {
				$name  = self::literal( $tokens[ $i + 2 ] );
				$value = self::literal( $tokens[ $i + 4 ] );
				if ( is_string( $name ) && in_array( $name, $wanted, true ) && ',' === $tokens[ $i + 3 ] && null !== $value && ')' === $tokens[ $i + 5 ] ) {
					$out[ $name ] = $value;
				}
			}
			// The table prefix, assigned a quoted string.
			if ( is_array( $t ) && T_VARIABLE === $t[0] && '$table_prefix' === $t[1] && isset( $tokens[ $i + 3 ] ) && '=' === $tokens[ $i + 1 ] && ';' === $tokens[ $i + 3 ] ) {
				$value = self::literal( $tokens[ $i + 2 ] );
				if ( is_string( $value ) ) {
					$out['table_prefix'] = $value;
				}
			}
		}
		return $out;
	}

	/**
	 * A token's literal value: a quoted string without variables, true, false or a number.
	 *
	 * @param mixed $t Token.
	 * @return string|bool|null
	 */
	private static function literal( $t ) {
		if ( ! is_array( $t ) ) {
			return null;
		}
		if ( T_CONSTANT_ENCAPSED_STRING === $t[0] ) {
			$q    = $t[1][0];
			$body = substr( $t[1], 1, -1 );
			return "'" === $q ? str_replace( array( '\\\\', "\\'" ), array( '\\', "'" ), $body ) : stripcslashes( $body );
		}
		if ( T_STRING === $t[0] && in_array( strtolower( $t[1] ), array( 'true', 'false' ), true ) ) {
			return 'true' === strtolower( $t[1] );
		}
		if ( T_LNUMBER === $t[0] ) {
			return $t[1];
		}
		return null;
	}

	/**
	 * The database facts of an install, in the shape the dashboard's integrity report takes
	 * (src/integrity/report.ts), plus the site address. Null with a reason when it can't be read.
	 *
	 * @param string $install Absolute path of the install.
	 * @return array{facts: ?array, home: string, note: string}
	 */
	public static function read( $install ) {
		$out  = array(
			'facts' => null,
			'home'  => '',
			'note'  => '',
		);
		$file = IsHacked_Cli_Fs::is_file( $install . '/wp-config.php' ) ? $install . '/wp-config.php' : dirname( $install ) . '/wp-config.php';
		$text = IsHacked_Cli_Fs::head( $file, 1048576 );
		if ( null === $text ) {
			$out['note'] = 'wp-config.php unreadable.';
			return $out;
		}
		$c = self::config( $text );
		if ( isset( $c['WP_HOME'] ) && is_string( $c['WP_HOME'] ) ) {
			$out['home'] = $c['WP_HOME'];
		}
		$prefix = isset( $c['table_prefix'] ) ? $c['table_prefix'] : '';
		foreach ( array( 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST' ) as $name ) {
			if ( ! isset( $c[ $name ] ) || ! is_string( $c[ $name ] ) ) {
				$out['note'] = $name . ' in wp-config.php is not a plain value; the database was not read.';
				return $out;
			}
		}
		if ( ! preg_match( '/^[A-Za-z0-9_]{1,64}$/', $prefix ) ) {
			$out['note'] = '$table_prefix in wp-config.php is not a plain value; the database was not read.';
			return $out;
		}
		if ( ! function_exists( 'mysqli_init' ) ) {
			$out['note'] = 'PHP has no mysqli extension; the database was not read.';
			return $out;
		}
		$db = self::connect( $c['DB_HOST'], $c['DB_USER'], $c['DB_PASSWORD'], $c['DB_NAME'] );
		if ( is_string( $db ) ) {
			$out['note'] = 'Database connection failed: ' . $db;
			return $out;
		}
		try {
			$out['facts'] = self::facts( $db, $prefix );
			$out['home']  = '' !== $out['home'] ? $out['home'] : $out['facts']['homeUrl'];
			unset( $out['facts']['homeUrl'] );
		} catch ( Exception $e ) {
			$out['note'] = 'Database read failed: ' . substr( $e->getMessage(), 0, 200 );
		}
		$db->close();
		return $out;
	}

	/**
	 * Connects with WordPress's DB_HOST forms: host, host:port, host:/socket, localhost:/socket.
	 *
	 * @param string $host     DB_HOST.
	 * @param string $user     User.
	 * @param string $password Password.
	 * @param string $name     Database.
	 * @return mysqli|string The connection, or why it failed.
	 */
	private static function connect( $host, $user, $password, $name ) {
		$port   = null;
		$socket = null;
		if ( preg_match( '/^(.*?):(\d+)$/', $host, $m ) ) {
			$host = $m[1];
			$port = (int) $m[2];
		} elseif ( preg_match( '/^(.*?):(\/.+)$/', $host, $m ) ) {
			$host   = '' === $m[1] ? 'localhost' : $m[1];
			$socket = $m[2];
		}
		mysqli_report( MYSQLI_REPORT_OFF );
		$db = mysqli_init();
		$db->options( MYSQLI_OPT_CONNECT_TIMEOUT, 5 );
		if ( ! @$db->real_connect( $host, $user, $password, $name, $port, $socket ) ) {
			return substr( (string) mysqli_connect_error(), 0, 200 );
		}
		$db->set_charset( 'utf8mb4' );
		return $db;
	}

	/**
	 * The facts, read in one read-only transaction.
	 *
	 * @param mysqli $db     Connection.
	 * @param string $prefix Table prefix (checked: letters, digits, underscores).
	 * @return array
	 * @throws RuntimeException When a query fails.
	 */
	private static function facts( mysqli $db, $prefix ) {
		$q = function ( $sql ) use ( $db ) {
			$r = $db->query( $sql );
			if ( false === $r ) {
				throw new RuntimeException( $db->error );
			}
			return true === $r ? array() : $r->fetch_all( MYSQLI_ASSOC );
		};
		$q( 'SET SESSION TRANSACTION READ ONLY' );
		$q( 'START TRANSACTION' );
		$limit   = self::MAX_ROWS + 1;
		$admins  = $q(
			"SELECT u.ID AS id, u.user_login AS login, m.meta_value AS roles,
				(SELECT a.meta_value FROM {$prefix}usermeta a WHERE a.user_id = u.ID AND a.meta_key = '_application_passwords' LIMIT 1) AS passwords
			FROM {$prefix}users u JOIN {$prefix}usermeta m ON m.user_id = u.ID
			WHERE m.meta_key = '{$prefix}capabilities' AND m.meta_value LIKE '%\"administrator\";b:1%' ORDER BY u.ID LIMIT $limit"
		);
		$options = $q( "SELECT option_name AS name, option_value AS value FROM {$prefix}options WHERE option_name IN ('cron', 'active_plugins', 'siteurl', 'home', 'users_can_register', 'default_role')" );
		$posts   = $q( "SELECT ID AS id FROM {$prefix}posts WHERE post_content LIKE '%<script%' OR post_content LIKE '%<iframe%' ORDER BY ID LIMIT $limit" );
		$q( 'ROLLBACK' );
		$values = array();
		foreach ( $options as $o ) {
			$values[ $o['name'] ] = (string) $o['value'];
		}
		$out = array(
			'complete'          => count( $admins ) <= self::MAX_ROWS && count( $posts ) <= self::MAX_ROWS,
			'administrators'    => array(),
			'options'           => array(),
			'suspiciousPostIds' => array(),
			'registration'      => array(
				'open'        => isset( $values['users_can_register'] ) && '1' === $values['users_can_register'],
				'defaultRole' => isset( $values['default_role'] ) ? substr( $values['default_role'], 0, 60 ) : '',
			),
			'homeUrl'           => isset( $values['home'] ) ? $values['home'] : '',
		);
		foreach ( array_slice( $admins, 0, self::MAX_ROWS ) as $a ) {
			$out['administrators'][] = array(
				'id'            => (string) $a['id'],
				'login'         => substr( (string) $a['login'], 0, 200 ),
				'rolesHash'     => hash( 'sha256', (string) $a['roles'] ),
				'passwordsHash' => hash( 'sha256', (string) $a['passwords'] ),
			);
		}
		foreach ( array( 'cron', 'active_plugins', 'siteurl', 'home' ) as $name ) {
			if ( isset( $values[ $name ] ) ) {
				$out['options'][] = array(
					'name'   => $name,
					'sha256' => hash( 'sha256', $values[ $name ] ),
				);
			}
		}
		foreach ( array_slice( $posts, 0, self::MAX_ROWS ) as $p ) {
			$out['suspiciousPostIds'][] = (string) $p['id'];
		}
		return $out;
	}
}
<?php
/**
 * Read-only file access. Files are opened only when they're regular files, never through a symlink,
 * and the opened file must be the one lstat() saw (a symlink swapped in between is refused). Nothing
 * is ever included or run.
 *
 * @package IsHacked
 */

/**
 * Safe reads and the folder walk.
 */
final class IsHacked_Cli_Fs {

	/** Files larger than this are listed but not hashed (as in the plugin). */
	const MAX_HASH_BYTES = 33554432;

	/** Folders never walked. */
	const SKIP_NAMES = array( '.git', '.svn', '.hg', 'node_modules' );

	/** Deepest folder walked below a root. */
	const MAX_DEPTH = 40;

	/**
	 * Opens a regular file for reading.
	 *
	 * @param string $abs Absolute path.
	 * @return array{0: resource, 1: array}|null Handle and lstat(), or null.
	 */
	public static function open( $abs ) {
		$st = @lstat( $abs );
		if ( ! $st || 0100000 !== ( $st['mode'] & 0170000 ) ) {
			return null;
		}
		$h = @fopen( $abs, 'rb' );
		if ( ! $h ) {
			return null;
		}
		$fs = fstat( $h );
		if ( ! $fs || $fs['ino'] !== $st['ino'] || $fs['dev'] !== $st['dev'] ) {
			fclose( $h );
			return null;
		}
		return array( $h, $st );
	}

	/**
	 * The first bytes of a regular file, or null.
	 *
	 * @param string $abs   Absolute path.
	 * @param int    $bytes How much.
	 * @return string|null
	 */
	public static function head( $abs, $bytes ) {
		$f = self::open( $abs );
		if ( ! $f ) {
			return null;
		}
		$text = stream_get_contents( $f[0], $bytes );
		fclose( $f[0] );
		return false === $text ? null : $text;
	}

	/**
	 * Hashes a file in one read, keeping its text when it's small enough to scan.
	 *
	 * @param string $abs       Absolute path.
	 * @param int    $max_text  Largest file whose text is kept.
	 * @return array{md5: string, sha256: string, text: ?string, size: int, mtime: int}|null Null when unreadable.
	 */
	public static function digest( $abs, $max_text ) {
		$f = self::open( $abs );
		if ( ! $f ) {
			return null;
		}
		list( $h, $st ) = $f;
		$out            = array(
			'md5'    => '',
			'sha256' => '',
			'text'   => null,
			'size'   => (int) $st['size'],
			'mtime'  => (int) $st['mtime'],
		);
		if ( $out['size'] > self::MAX_HASH_BYTES ) {
			fclose( $h );
			return $out;
		}
		$md5  = hash_init( 'md5' );
		$sha  = hash_init( 'sha256' );
		$keep = $out['size'] <= $max_text;
		$text = '';
		while ( ! feof( $h ) ) {
			$chunk = fread( $h, 1048576 );
			if ( false === $chunk ) {
				fclose( $h );
				return null;
			}
			hash_update( $md5, $chunk );
			hash_update( $sha, $chunk );
			if ( $keep ) {
				$text .= $chunk;
			}
		}
		fclose( $h );
		$out['md5']    = hash_final( $md5 );
		$out['sha256'] = hash_final( $sha );
		$out['text']   = $keep ? $text : null;
		return $out;
	}

	/**
	 * Entries of a folder, sorted, without . and .., or null when it can't be read.
	 *
	 * @param string $dir Absolute path.
	 * @return string[]|null
	 */
	public static function entries( $dir ) {
		$names = @scandir( $dir );
		if ( false === $names ) {
			return null;
		}
		return array_values( array_diff( $names, array( '.', '..' ) ) );
	}

	/**
	 * Whether a path is a real folder (not a symlink to one).
	 *
	 * @param string $abs Absolute path.
	 * @return bool
	 */
	public static function is_dir( $abs ) {
		$st = @lstat( $abs );
		return $st && 0040000 === ( $st['mode'] & 0170000 );
	}

	/**
	 * Whether a path is a regular file (not a symlink).
	 *
	 * @param string $abs Absolute path.
	 * @return bool
	 */
	public static function is_file( $abs ) {
		$st = @lstat( $abs );
		return $st && 0100000 === ( $st['mode'] & 0170000 );
	}

	/**
	 * Whether a path matches one of the --exclude patterns (shell globs, matched against the absolute
	 * path, the path below the root and the name).
	 *
	 * @param string   $abs      Absolute path.
	 * @param string   $rel      Path below the root.
	 * @param string[] $patterns Patterns.
	 * @return bool
	 */
	public static function excluded( $abs, $rel, array $patterns ) {
		foreach ( $patterns as $p ) {
			if ( fnmatch( $p, $abs ) || fnmatch( $p, $rel ) || fnmatch( $p, basename( $abs ) ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Walks a folder depth first, calling $each( $rel, $abs ) for every regular file. Symlinks are never
	 * followed. Folders in $prune (absolute paths) and below $skip_rel (paths below the root) are left
	 * out, as are version control and node_modules folders.
	 *
	 * @param string   $root     Absolute folder.
	 * @param string   $start    Path below the root to start at ('' for all of it).
	 * @param array    $opt      prune (abs => true), skip_rel (string[]), exclude (string[]).
	 * @param callable $each     Called with ($rel, $abs).
	 * @param array    $coverage Counters, updated in place: files, folders, and skipped reasons.
	 */
	public static function walk( $root, $start, array $opt, $each, array &$coverage ) {
		$prune    = isset( $opt['prune'] ) ? $opt['prune'] : array();
		$skip_rel = isset( $opt['skip_rel'] ) ? $opt['skip_rel'] : array();
		$exclude  = isset( $opt['exclude'] ) ? $opt['exclude'] : array();
		$stack    = array( array( '' === $start ? $root : $root . '/' . $start, $start, 0 ) );
		while ( $stack ) {
			list( $dir, $rel_dir, $depth ) = array_pop( $stack );
			$names                         = self::entries( $dir );
			if ( null === $names ) {
				self::skip( $coverage, 'unreadable-folder', $rel_dir );
				continue;
			}
			++$coverage['folders'];
			$subdirs = array();
			foreach ( $names as $name ) {
				$abs = $dir . '/' . $name;
				$rel = '' === $rel_dir ? $name : $rel_dir . '/' . $name;
				$st  = @lstat( $abs );
				if ( ! $st ) {
					self::skip( $coverage, 'unreadable-file', $rel );
					continue;
				}
				$type = $st['mode'] & 0170000;
				if ( 0120000 === $type ) {
					++$coverage['symlinks'];
					continue;
				}
				if ( $exclude && self::excluded( $abs, $rel, $exclude ) ) {
					++$coverage['excluded'];
					continue;
				}
				if ( 0040000 === $type ) {
					if ( in_array( $name, self::SKIP_NAMES, true ) || in_array( $rel, $skip_rel, true ) || isset( $prune[ $abs ] ) ) {
						continue;
					}
					if ( $depth + 1 > self::MAX_DEPTH ) {
						self::skip( $coverage, 'depth-limit', $rel );
						continue;
					}
					$subdirs[] = array( $abs, $rel, $depth + 1 );
				} elseif ( 0100000 === $type ) {
					call_user_func( $each, $rel, $abs );
				}
			}
			// Reversed, so the stack pops them in name order.
			foreach ( array_reverse( $subdirs ) as $sub ) {
				$stack[] = $sub;
			}
		}
	}

	/**
	 * Notes something left out.
	 *
	 * @param array  $coverage Counters.
	 * @param string $reason   Why.
	 * @param string $path     What.
	 */
	public static function skip( array &$coverage, $reason, $path ) {
		$coverage['complete'] = false;
		if ( ! isset( $coverage['skipped'][ $reason ] ) ) {
			$coverage['skipped'][ $reason ] = array(
				'count'    => 0,
				'examples' => array(),
			);
		}
		++$coverage['skipped'][ $reason ]['count'];
		if ( count( $coverage['skipped'][ $reason ]['examples'] ) < 5 ) {
			$coverage['skipped'][ $reason ]['examples'][] = $path;
		}
	}

	/**
	 * Empty coverage counters.
	 *
	 * @return array
	 */
	public static function coverage() {
		return array(
			'complete' => true,
			'files'    => 0,
			'folders'  => 0,
			'symlinks' => 0,
			'excluded' => 0,
			'skipped'  => array(),
			'notes'    => array(),
		);
	}
}
<?php
/**
 * Finds WordPress installs under folders and reads what they run (version, plugins, themes) from
 * their files, the way WordPress would, without loading WordPress.
 *
 * @package IsHacked
 */

/**
 * Install discovery and inventory.
 */
final class IsHacked_Cli_Installs {

	/** Drop-ins WordPress loads from wp-content when present. */
	const DROPINS = array( 'advanced-cache.php', 'object-cache.php', 'db.php', 'sunrise.php', 'db-error.php', 'maintenance.php', 'fatal-error-handler.php', 'install.php', 'php-error.php', 'blog-deleted.php', 'blog-inactive.php', 'blog-suspended.php' );

	/** Most folders looked at while discovering, per path given. */
	const MAX_FOLDERS = 500000;

	/**
	 * Whether a folder is a WordPress install.
	 *
	 * @param string $dir Absolute path.
	 * @return bool
	 */
	public static function is_install( $dir ) {
		return IsHacked_Cli_Fs::is_file( $dir . '/wp-includes/version.php' ) && IsHacked_Cli_Fs::is_file( $dir . '/wp-load.php' );
	}

	/**
	 * The install a path is inside of (itself, or up to 10 folders up), or ''.
	 *
	 * @param string $abs Absolute path.
	 * @return string
	 */
	public static function containing( $abs ) {
		for ( $dir = $abs, $i = 0; $i <= 10 && '' !== $dir; ++$i ) {
			if ( self::is_install( $dir ) ) {
				return $dir;
			}
			$up = dirname( $dir );
			if ( $up === $dir ) {
				break;
			}
			$dir = $up;
		}
		return '';
	}

	/**
	 * Installs under a folder, breadth first up to $max_depth folders down. Installs inside installs
	 * (a blog in a subfolder) are found too.
	 *
	 * @param string   $root      Absolute folder.
	 * @param int      $max_depth How deep.
	 * @param string[] $exclude   --exclude patterns.
	 * @param array    $coverage  Counters, updated in place.
	 * @return string[] Absolute paths.
	 */
	public static function discover( $root, $max_depth, array $exclude, array &$coverage ) {
		$found = array();
		$queue = array( array( $root, '', 0 ) );
		// An index into the queue rather than array_shift(), which is slow on long queues.
		for ( $next = 0; isset( $queue[ $next ] ); ++$next ) {
			list( $dir, $rel, $depth ) = $queue[ $next ];
			if ( $next >= self::MAX_FOLDERS ) {
				IsHacked_Cli_Fs::skip( $coverage, 'discovery-limit', $rel );
				break;
			}
			$install = self::is_install( $dir );
			if ( $install ) {
				$found[] = $dir;
			}
			if ( $depth >= $max_depth ) {
				continue;
			}
			$names = IsHacked_Cli_Fs::entries( $dir );
			if ( null === $names ) {
				IsHacked_Cli_Fs::skip( $coverage, 'unreadable-folder', '' === $rel ? $root : $rel );
				continue;
			}
			foreach ( $names as $name ) {
				$abs = $dir . '/' . $name;
				$sub = '' === $rel ? $name : $rel . '/' . $name;
				if ( in_array( $name, IsHacked_Cli_Fs::SKIP_NAMES, true ) || ! IsHacked_Cli_Fs::is_dir( $abs ) || ( $exclude && IsHacked_Cli_Fs::excluded( $abs, $sub, $exclude ) ) ) {
					continue;
				}
				if ( $install && in_array( $name, array( 'wp-admin', 'wp-includes' ), true ) ) {
					continue;
				}
				// Folders inside an install that never hold another one.
				if ( preg_match( '#(?:^|/)wp-content/(?:plugins|themes|uploads|cache|languages|upgrade)$#', $sub ) ) {
					continue;
				}
				$queue[] = array( $abs, $sub, $depth + 1 );
			}
		}
		return $found;
	}

	/**
	 * What an install runs, from its files.
	 *
	 * @param string $dir Absolute path of the install.
	 * @return array
	 */
	public static function inventory( $dir ) {
		$version = (string) IsHacked_Cli_Fs::head( $dir . '/wp-includes/version.php', 65536 );
		$inv     = array(
			'path'      => $dir,
			'version'   => preg_match( '/\$wp_version\s*=\s*[\'"]([\w.+-]{1,30})[\'"]/', $version, $m ) ? $m[1] : '',
			'locale'    => preg_match( '/\$wp_local_package\s*=\s*[\'"]([A-Za-z_]{2,20})[\'"]/', $version, $m ) ? $m[1] : 'en_US',
			'content'   => IsHacked_Cli_Fs::is_dir( $dir . '/wp-content' ),
			'plugins'   => array(),
			'themes'    => array(),
			'mu'        => array(),
			'dropins'   => array(),
			'multisite' => false,
			'notes'     => array(),
		);
		if ( '' === $inv['version'] ) {
			$inv['notes'][] = 'WordPress version unreadable.';
		}
		$config = IsHacked_Cli_Fs::head( $dir . '/wp-config.php', 262144 );
		if ( null === $config && IsHacked_Cli_Fs::is_file( dirname( $dir ) . '/wp-config.php' ) ) {
			$config = IsHacked_Cli_Fs::head( dirname( $dir ) . '/wp-config.php', 262144 );
		}
		$inv['multisite'] = null !== $config && (bool) preg_match( '/define\s*\(\s*[\'"]MULTISITE[\'"]\s*,\s*true\s*\)/i', $config );
		if ( ! $inv['content'] ) {
			$inv['notes'][] = 'No wp-content folder here (moved with WP_CONTENT_DIR?); plugins and themes not checked.';
			return $inv;
		}
		$content = $dir . '/wp-content';
		foreach ( (array) IsHacked_Cli_Fs::entries( $content . '/plugins' ) as $name ) {
			$abs = $content . '/plugins/' . $name;
			if ( IsHacked_Cli_Fs::is_dir( $abs ) ) {
				foreach ( (array) IsHacked_Cli_Fs::entries( $abs ) as $file ) {
					if ( '.php' !== substr( $file, -4 ) ) {
						continue;
					}
					$h = IsHacked_Headers::plugin( (string) IsHacked_Cli_Fs::head( $abs . '/' . $file, IsHacked_Headers::BYTES ) );
					if ( $h ) {
						$inv['plugins'][ $name ] = array(
							'file'    => $name . '/' . $file,
							'name'    => $h['name'],
							'version' => $h['version'],
						);
						break;
					}
				}
			} elseif ( '.php' === substr( $name, -4 ) && 'index.php' !== $name ) {
				$h = IsHacked_Headers::plugin( (string) IsHacked_Cli_Fs::head( $abs, IsHacked_Headers::BYTES ) );
				if ( $h ) {
					$inv['plugins'][ substr( $name, 0, -4 ) ] = array(
						'file'    => $name,
						'name'    => $h['name'],
						'version' => $h['version'],
					);
				}
			}
		}
		foreach ( (array) IsHacked_Cli_Fs::entries( $content . '/themes' ) as $name ) {
			$h = IsHacked_Cli_Fs::is_dir( $content . '/themes/' . $name ) ? IsHacked_Headers::theme( (string) IsHacked_Cli_Fs::head( $content . '/themes/' . $name . '/style.css', IsHacked_Headers::BYTES ) ) : null;
			if ( $h ) {
				$inv['themes'][ $name ] = $h;
			}
		}
		foreach ( (array) IsHacked_Cli_Fs::entries( $content . '/mu-plugins' ) as $name ) {
			if ( '.php' === substr( $name, -4 ) && IsHacked_Cli_Fs::is_file( $content . '/mu-plugins/' . $name ) ) {
				$inv['mu'][] = $name;
			}
		}
		foreach ( self::DROPINS as $name ) {
			if ( IsHacked_Cli_Fs::is_file( $content . '/' . $name ) ) {
				$inv['dropins'][] = $name;
			}
		}
		return $inv;
	}

	/**
	 * Plugins worth asking wordpress.org about: a folder, a plausible slug and version (as the plugin
	 * and the dashboard do).
	 *
	 * @param array $inv From inventory().
	 * @return array<string, string> slug => version
	 */
	public static function wporg_candidates( array $inv ) {
		$out = array();
		foreach ( $inv['plugins'] as $slug => $p ) {
			if ( false !== strpos( $p['file'], '/' ) && preg_match( '/^[a-z0-9-]{1,100}$/', (string) $slug ) && preg_match( '/^\d[\w.-]{0,30}$/', $p['version'] ) ) {
				$out[ (string) $slug ] = $p['version'];
			}
		}
		return $out;
	}
}
<?php
/**
 * Lookups on wordpress.org and wpvulnerability.net, cached on disk. Only these hosts are contacted,
 * over HTTPS with certificate checks, and only WordPress versions and plugin and theme names are
 * sent. Several requests run at once when PHP has curl.
 *
 * @package IsHacked
 */

/**
 * JSON over HTTPS with a disk cache.
 */
final class IsHacked_Cli_Lookups {

	/** The only hosts ever contacted. */
	const HOSTS = array( 'api.wordpress.org', 'downloads.wordpress.org', 'www.wpvulnerability.net' );

	/** Largest answer read. */
	const MAX_BYTES = 4194304;

	/** Requests at once. */
	const PARALLEL = 4;

	/**
	 * Cache folder, or '' when there's none.
	 *
	 * @var string
	 */
	private $dir;

	/**
	 * Whether the network is off (--offline).
	 *
	 * @var bool
	 */
	private $offline;

	/**
	 * Replaces the network in tests: callable( $url ) returning array( status, body ) or null.
	 *
	 * @var callable|null
	 */
	private $transport;

	/**
	 * Lookups that got no usable answer this run.
	 *
	 * @var int
	 */
	public $failed = 0;

	/**
	 * Answers by URL, cached or fetched this run.
	 *
	 * @var array<string, array{0: int, 1: mixed}>
	 */
	private $mem = array();

	/**
	 * Sets up the cache.
	 *
	 * @param string        $dir       Cache folder ('' for the default, false-y to disable).
	 * @param bool          $offline   No network.
	 * @param callable|null $transport Test stand-in for the network.
	 */
	public function __construct( $dir, $offline, $transport = null ) {
		$this->offline   = $offline;
		$this->transport = $transport;
		$this->dir       = self::cache_dir( $dir );
	}

	/**
	 * The cache folder, created 0700 when missing, or '' when it can't be used.
	 *
	 * @param string $dir --cache-dir value, or ''.
	 * @return string
	 */
	private static function cache_dir( $dir ) {
		if ( '' === $dir ) {
			$xdg  = getenv( 'XDG_CACHE_HOME' );
			$home = getenv( 'HOME' );
			$dir  = $xdg ? $xdg . '/ishacked' : ( $home ? $home . '/.cache/ishacked' : '' );
		}
		if ( '' === $dir || 'none' === $dir ) {
			return '';
		}
		if ( ! is_dir( $dir ) && ! @mkdir( $dir, 0700, true ) ) {
			return '';
		}
		return is_writable( $dir ) && ! is_link( $dir ) ? rtrim( $dir, '/' ) : '';
	}

	/**
	 * Decoded JSON answers for many URLs: url => array( HTTP status, decoded body ). A URL is missing
	 * from the result when no answer is available (offline, failed or not allowed). 200 and 404 answers
	 * are cached for $ttl seconds; failures aren't, so the next run tries again.
	 *
	 * @param array<string, int> $urls url => ttl in seconds.
	 * @return array<string, array{0: int, 1: mixed}>
	 */
	public function json_many( array $urls ) {
		$out  = array();
		$todo = array();
		foreach ( $urls as $url => $ttl ) {
			if ( isset( $this->mem[ $url ] ) ) {
				$out[ $url ] = $this->mem[ $url ];
				continue;
			}
			$cached = $this->read_cache( $url, $ttl );
			if ( null !== $cached ) {
				$this->mem[ $url ] = $cached;
				$out[ $url ]       = $cached;
				continue;
			}
			if ( ! self::allowed( $url ) ) {
				++$this->failed;
				continue;
			}
			$todo[] = $url;
		}
		if ( $this->offline ) {
			$this->failed += count( $todo );
			return $out;
		}
		foreach ( $this->fetch( $todo ) as $url => $answer ) {
			if ( null === $answer || ( 200 !== $answer[0] && 404 !== $answer[0] ) ) {
				++$this->failed;
				continue;
			}
			$decoded = json_decode( $answer[1], true );
			if ( 200 === $answer[0] && null === $decoded ) {
				++$this->failed;
				continue;
			}
			$this->mem[ $url ] = array( $answer[0], $decoded );
			$out[ $url ]       = $this->mem[ $url ];
			$this->write_cache( $url, $out[ $url ] );
		}
		return $out;
	}

	/**
	 * Whether a URL may be fetched: HTTPS to one of HOSTS, no credentials or port.
	 *
	 * @param string $url URL.
	 * @return bool
	 */
	public static function allowed( $url ) {
		$p = parse_url( $url );
		return is_array( $p ) && isset( $p['scheme'], $p['host'] ) && 'https' === $p['scheme'] && in_array( $p['host'], self::HOSTS, true ) && ! isset( $p['user'] ) && ! isset( $p['pass'] ) && ! isset( $p['port'] );
	}

	/**
	 * Raw answers: url => array( status, body ) or null.
	 *
	 * @param string[] $urls URLs.
	 * @return array<string, array{0: int, 1: string}|null>
	 */
	private function fetch( array $urls ) {
		if ( ! $urls ) {
			return array();
		}
		if ( $this->transport ) {
			$out = array();
			foreach ( $urls as $url ) {
				$out[ $url ] = call_user_func( $this->transport, $url );
			}
			return $out;
		}
		return function_exists( 'curl_multi_init' ) ? self::fetch_curl( $urls ) : self::fetch_streams( $urls );
	}

	/**
	 * Fetches with curl, PARALLEL at a time, following at most one redirect to an allowed host.
	 *
	 * @param string[] $urls URLs.
	 * @return array<string, array{0: int, 1: string}|null>
	 */
	private static function fetch_curl( array $urls ) {
		$out   = array();
		$queue = array();
		foreach ( $urls as $url ) {
			$queue[] = array( $url, $url, 0 );
		}
		$multi   = curl_multi_init();
		$running = array();
		$bodies  = array();
		while ( $queue || $running ) {
			while ( $queue && self::PARALLEL > count( $running ) ) { // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- $running changes inside the loop.
				list( $original, $url, $hops ) = array_shift( $queue );
				$ch                            = curl_init( $url );
				$id                            = (int) ( is_object( $ch ) ? spl_object_id( $ch ) : $ch );
				$bodies[ $id ]                 = '';
				curl_setopt_array(
					$ch,
					array(
						CURLOPT_RETURNTRANSFER => false,
						CURLOPT_FOLLOWLOCATION => false,
						CURLOPT_PROTOCOLS      => CURLPROTO_HTTPS,
						CURLOPT_SSL_VERIFYPEER => true,
						CURLOPT_SSL_VERIFYHOST => 2,
						CURLOPT_CONNECTTIMEOUT => 8,
						CURLOPT_TIMEOUT        => 20,
						CURLOPT_USERAGENT      => self::user_agent(),
						CURLOPT_ENCODING       => '',
						CURLOPT_WRITEFUNCTION  => function ( $handle, $chunk ) use ( &$bodies, $id ) {
							if ( strlen( $bodies[ $id ] ) + strlen( $chunk ) > self::MAX_BYTES ) {
								return 0;
							}
							$bodies[ $id ] .= $chunk;
							return strlen( $chunk );
						},
					)
				);
				curl_multi_add_handle( $multi, $ch );
				$running[ $id ] = array( $ch, $original, $hops );
			}
			curl_multi_exec( $multi, $active );
			if ( $active ) {
				curl_multi_select( $multi, 1.0 );
			}
			while ( false !== ( $info = curl_multi_info_read( $multi ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- Draining curl's message queue.
				$ch                        = $info['handle'];
				$id                        = (int) ( is_object( $ch ) ? spl_object_id( $ch ) : $ch );
				list( , $original, $hops ) = $running[ $id ];
				$status                    = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
				$location                  = (string) curl_getinfo( $ch, CURLINFO_REDIRECT_URL );
				$ok                        = CURLE_OK === $info['result'];
				curl_multi_remove_handle( $multi, $ch );
				if ( PHP_VERSION_ID < 80000 ) {
					curl_close( $ch ); // phpcs:ignore Generic.PHP.DeprecatedFunctions.Deprecated -- A no-op since PHP 8, deprecated in 8.5.
				}
				unset( $running[ $id ] );
				if ( $ok && $status >= 300 && $status < 400 && 0 === $hops && self::allowed( $location ) ) {
					$queue[] = array( $original, $location, 1 );
				} else {
					$out[ $original ] = $ok ? array( $status, $bodies[ $id ] ) : null;
				}
				unset( $bodies[ $id ] );
			}
		}
		curl_multi_close( $multi );
		return $out;
	}

	/**
	 * Fetches with PHP streams, one at a time, when curl isn't installed.
	 *
	 * @param string[] $urls URLs.
	 * @return array<string, array{0: int, 1: string}|null>
	 */
	private static function fetch_streams( array $urls ) {
		$out = array();
		if ( ! ini_get( 'allow_url_fopen' ) ) {
			return array_fill_keys( $urls, null );
		}
		foreach ( $urls as $url ) {
			$context = stream_context_create(
				array(
					'http' => array(
						'timeout'         => 20,
						'follow_location' => 0,
						'ignore_errors'   => true,
						'user_agent'      => self::user_agent(),
					),
					'ssl'  => array(
						'verify_peer'      => true,
						'verify_peer_name' => true,
					),
				)
			);
			$h       = @fopen( $url, 'rb', false, $context );
			if ( ! $h ) {
				$out[ $url ] = null;
				continue;
			}
			$meta = stream_get_meta_data( $h );
			$body = stream_get_contents( $h, self::MAX_BYTES );
			fclose( $h );
			$status = 0;
			foreach ( isset( $meta['wrapper_data'] ) ? (array) $meta['wrapper_data'] : array() as $line ) {
				if ( preg_match( '#^HTTP/\S+\s+(\d{3})#', (string) $line, $m ) ) {
					$status = (int) $m[1];
				}
			}
			$out[ $url ] = false === $body || 0 === $status ? null : array( $status, $body );
		}
		return $out;
	}

	/**
	 * The User-Agent sent.
	 *
	 * @return string
	 */
	private static function user_agent() {
		return 'ishacked-cli/' . ISHACKED_CLI_VERSION . ' (+https://ishacked.com/cli)';
	}

	/**
	 * A cached answer younger than $ttl, or null.
	 *
	 * @param string $url URL.
	 * @param int    $ttl Seconds.
	 * @return array{0: int, 1: mixed}|null
	 */
	private function read_cache( $url, $ttl ) {
		if ( '' === $this->dir ) {
			return null;
		}
		$file = $this->dir . '/' . sha1( $url ) . '.json';
		$st   = @lstat( $file );
		if ( ! $st || 0100000 !== ( $st['mode'] & 0170000 ) || time() - $st['mtime'] > $ttl ) {
			return null;
		}
		$data = json_decode( (string) @file_get_contents( $file ), true );
		return is_array( $data ) && isset( $data['url'], $data['status'] ) && $url === $data['url'] && array_key_exists( 'body', $data ) ? array( (int) $data['status'], $data['body'] ) : null;
	}

	/**
	 * Caches an answer, written to a temporary file first so a crash never leaves half a file.
	 *
	 * @param string $url    URL.
	 * @param array  $answer array( status, decoded body ).
	 */
	private function write_cache( $url, array $answer ) {
		if ( '' === $this->dir ) {
			return;
		}
		$file = $this->dir . '/' . sha1( $url ) . '.json';
		$tmp  = $file . '.' . getmypid() . '.tmp';
		$json = json_encode(
			array(
				'url'    => $url,
				'status' => $answer[0],
				'body'   => $answer[1],
			)
		);
		if ( false !== $json && false !== @file_put_contents( $tmp, $json ) ) {
			@chmod( $tmp, 0600 );
			if ( ! @rename( $tmp, $file ) ) {
				@unlink( $tmp );
			}
		}
	}
}
<?php
/**
 * Output formats: human (on a terminal), tsv (the default when piped), csv and json. File names come
 * from the scanned server and can carry anything, so control characters never reach a terminal, and
 * CSV cells that a spreadsheet would run as a formula get a leading quote (as src/web/csv.ts).
 *
 * @package IsHacked
 */

/**
 * Formats rows.
 */
final class IsHacked_Cli_Output {

	const FORMATS = array( 'human', 'tsv', 'csv', 'json' );

	/**
	 * Where output goes.
	 *
	 * @var resource
	 */
	private $out;

	/**
	 * Format.
	 *
	 * @var string
	 */
	private $format;

	/**
	 * Columns, in order.
	 *
	 * @var string[]
	 */
	private $columns;

	/**
	 * Whether to write a header row (tsv, csv).
	 *
	 * @var bool
	 */
	private $header;

	/**
	 * Sets up the output.
	 *
	 * @param resource $out     Stream.
	 * @param string   $format  One of FORMATS.
	 * @param string[] $columns Columns, in order.
	 * @param bool     $header  Header row.
	 */
	public function __construct( $out, $format, array $columns, $header ) {
		$this->out     = $out;
		$this->format  = $format;
		$this->columns = $columns;
		$this->header  = $header;
	}

	/**
	 * Writes the result.
	 *
	 * @param array[] $rows    Rows (column => value).
	 * @param array   $meta    For json: everything else (installs, coverage, summary).
	 * @param array   $summary Lines for the human format's summary.
	 */
	public function write( array $rows, array $meta, array $summary ) {
		if ( 'json' === $this->format ) {
			$doc         = $meta;
			$doc['rows'] = array();
			foreach ( $rows as $row ) {
				$doc['rows'][] = $this->pick( $row );
			}
			fwrite( $this->out, (string) json_encode( $doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) . "\n" );
			return;
		}
		if ( 'human' === $this->format ) {
			$this->human( $rows, $summary );
			return;
		}
		if ( $this->header ) {
			$this->line( $this->columns );
		}
		foreach ( $rows as $row ) {
			$this->line( array_values( $this->pick( $row ) ) );
		}
	}

	/**
	 * The row's chosen columns.
	 *
	 * @param array $row Row.
	 * @return array
	 */
	private function pick( array $row ) {
		$out = array();
		foreach ( $this->columns as $c ) {
			$out[ $c ] = isset( $row[ $c ] ) ? $row[ $c ] : '';
		}
		return $out;
	}

	/**
	 * One tsv or csv line.
	 *
	 * @param array $cells Values.
	 */
	private function line( array $cells ) {
		$out = array();
		foreach ( $cells as $v ) {
			$out[] = 'csv' === $this->format ? self::csv_cell( $v ) : self::clean( $v );
		}
		fwrite( $this->out, implode( 'csv' === $this->format ? ',' : "\t", $out ) . ( 'csv' === $this->format ? "\r\n" : "\n" ) );
	}

	/**
	 * The human format: rows grouped by install, worst first, then the summary.
	 *
	 * @param array[]  $rows    Rows.
	 * @param string[] $summary Summary lines.
	 */
	private function human( array $rows, array $summary ) {
		$by = array();
		foreach ( $rows as $row ) {
			$by[ isset( $row['install'] ) ? $row['install'] : '' ][] = $row;
		}
		foreach ( $by as $install => $group ) {
			fwrite( $this->out, "\n" . ( '' === $install ? 'Outside WordPress installs' : self::clean( $install ) ) . "\n" );
			foreach ( $group as $row ) {
				fwrite( $this->out, self::human_row( $row ) );
			}
		}
		fwrite( $this->out, ( $rows ? "\n" : '' ) . implode( "\n", array_map( array( __CLASS__, 'clean' ), $summary ) ) . "\n" );
	}

	/**
	 * One finding, vulnerability or install, on two lines: what and where, then why.
	 *
	 * @param array $row Row.
	 * @return string
	 */
	private static function human_row( array $row ) {
		$sev = isset( $row['severity'] ) ? str_pad( strtoupper( $row['severity'] ), 9 ) : '';
		if ( isset( $row['check'] ) ) {
			return '  ' . $sev . self::clean( $row['path'] ) . "\n" . str_repeat( ' ', 11 ) . self::clean( $row['check'] . ': ' . $row['detail'] ) . "\n";
		}
		if ( isset( $row['slug'] ) ) {
			$fix  = '' !== $row['fixed_in'] ? 'fixed in ' . $row['fixed_in'] : ( 'yes' === $row['unfixed'] ? 'no fix yet' : '' );
			$more = implode( ', ', array_filter( array( '' !== $row['cvss'] ? 'CVSS ' . $row['cvss'] : '', $fix, $row['link'] ) ) );
			return '  ' . $sev . self::clean( $row['type'] . ' ' . $row['slug'] . ' ' . $row['version'] ) . "\n" . str_repeat( ' ', 11 ) . self::clean( $row['title'] . ( '' !== $more ? ' (' . $more . ')' : '' ) ) . "\n";
		}
		return '  WordPress ' . self::clean( $row['wordpress'] . ' ' . $row['locale'] ) . ( 'yes' === $row['multisite'] ? ', multisite' : '' ) . ': ' . (int) $row['plugins'] . ' plugins, ' . (int) $row['themes'] . ' themes, ' . (int) $row['mu_plugins'] . ' must-use plugins, ' . (int) $row['dropins'] . " drop-ins\n";
	}

	/**
	 * A value without control characters, tabs or line breaks.
	 *
	 * @param mixed $v Value.
	 * @return string
	 */
	public static function clean( $v ) {
		return (string) preg_replace( '/[\x00-\x1f\x7f]/', ' ', is_bool( $v ) ? ( $v ? 'yes' : 'no' ) : (string) $v );
	}

	/**
	 * A quoted CSV cell; a leading quote stops spreadsheets running it as a formula.
	 *
	 * @param mixed $v Value.
	 * @return string
	 */
	public static function csv_cell( $v ) {
		$text = is_bool( $v ) ? ( $v ? 'yes' : 'no' ) : (string) $v;
		if ( preg_match( '/^[\s\x00-\x1f]*[=+\-@]/', $text ) || preg_match( '/^[\t\r\n]/', $text ) ) {
			$text = "'" . $text;
		}
		return '"' . str_replace( '"', '""', $text ) . '"';
	}
}
<?php
/**
 * Runs scan units (installs, and the files outside them) in several processes when PHP has pcntl.
 * Each worker writes its results to a private temporary file; a worker that fails has its units run
 * again in the main process.
 *
 * @package IsHacked
 */

/**
 * Process pool.
 */
final class IsHacked_Cli_Pool {

	/** Most workers. */
	const MAX = 8;

	/**
	 * Default worker count: the CPUs Linux reports, at most MAX; 1 elsewhere or without pcntl.
	 *
	 * @return int
	 */
	public static function default_workers() {
		if ( ! function_exists( 'pcntl_fork' ) ) {
			return 1;
		}
		$info = @file_get_contents( '/proc/cpuinfo' );
		$cpus = false === $info ? 1 : preg_match_all( '/^processor\s*:/m', $info );
		return max( 1, min( self::MAX, (int) $cpus ) );
	}

	/**
	 * Results of $work( $unit ) for every unit, in the units' order.
	 *
	 * @param array    $units   Units.
	 * @param int      $workers Processes.
	 * @param callable $work    Called with a unit; returns an array.
	 * @param callable $done    Called with ($index, $result) as results arrive (progress).
	 * @return array[]
	 */
	public static function run( array $units, $workers, $work, $done ) {
		$units   = array_values( $units );
		$workers = min( (int) $workers, count( $units ) );
		$results = array();
		if ( $workers > 1 && function_exists( 'pcntl_fork' ) ) {
			$results = self::forked( $units, $workers, $work, $done );
		}
		foreach ( $units as $i => $unit ) {
			if ( ! isset( $results[ $i ] ) ) {
				$results[ $i ] = call_user_func( $work, $unit );
				call_user_func( $done, $i, $results[ $i ] );
			}
		}
		ksort( $results );
		return $results;
	}

	/**
	 * Splits units round robin over forked workers.
	 *
	 * @param array    $units   Units.
	 * @param int      $workers Processes.
	 * @param callable $work    Work.
	 * @param callable $done    Progress.
	 * @return array<int, array> Results that came back.
	 */
	private static function forked( array $units, $workers, $work, $done ) {
		$children = array();
		for ( $w = 0; $w < $workers; ++$w ) {
			$file = tempnam( sys_get_temp_dir(), 'ishacked' );
			if ( false === $file ) {
				break;
			}
			$pid = pcntl_fork();
			if ( -1 === $pid ) {
				@unlink( $file );
				break;
			}
			if ( 0 === $pid ) {
				$out = array();
				foreach ( $units as $i => $unit ) {
					if ( $i % $workers === $w ) {
						$out[ $i ] = call_user_func( $work, $unit );
					}
				}
				file_put_contents( $file, serialize( $out ) );
				// Leave without running the parent's shutdown code a second time.
				if ( function_exists( 'posix_kill' ) && function_exists( 'posix_getpid' ) ) {
					posix_kill( posix_getpid(), SIGKILL );
				}
				exit( 0 );
			}
			$children[ $pid ] = $file;
		}
		$results = array();
		while ( $children ) {
			$pid = pcntl_wait( $status );
			if ( $pid <= 0 ) {
				break;
			}
			if ( ! isset( $children[ $pid ] ) ) {
				continue;
			}
			$file = $children[ $pid ];
			unset( $children[ $pid ] );
			$data = @unserialize( (string) @file_get_contents( $file ), array( 'allowed_classes' => false ) );
			@unlink( $file );
			if ( is_array( $data ) ) {
				foreach ( $data as $i => $result ) {
					$results[ $i ] = $result;
					call_user_func( $done, $i, $result );
				}
			}
		}
		foreach ( $children as $file ) {
			@unlink( $file );
		}
		return $results;
	}
}
<?php
/**
 * The checks: file rules and official checksums for `scan`, known vulnerabilities for `vuln-scan`.
 * The rules, the comparison with wordpress.org and the vulnerability ranges are the plugin's own
 * (IsHacked_Rules, IsHacked_Compare, IsHacked_Advisories).
 *
 * @package IsHacked
 */

/**
 * What a scan finds.
 */
final class IsHacked_Cli_Scan {

	/** Severities, from worst. Unrated vulnerabilities count as medium. */
	const RANK = array(
		'critical' => 5,
		'high'     => 4,
		'medium'   => 3,
		'unrated'  => 3,
		'low'      => 2,
		'info'     => 1,
	);

	/** Configuration and persistent code whose changes the dashboard tracks (as src/plugin/report.ts). */
	const TRACKED_RE = '#(?:^|/)(?:wp-config\.php|\.htaccess|\.user\.ini)$|^wp-content/(?:mu-plugins/|(?:advanced-cache|object-cache|db|sunrise)\.php$)#';

	/** Checksums are cached for a day (core) and a week (plugins), as in the plugin. */
	const CORE_TTL   = 86400;
	const PLUGIN_TTL = 604800;
	const VULN_TTL   = 86400;
	const INFO_TTL   = 604800;

	/**
	 * Severity and explanation of each check.
	 *
	 * @param string $check Check id.
	 * @return array{0: string, 1: string}
	 */
	public static function describe( $check ) {
		$checks = array(
			'executable-upload'   => array( 'high', 'PHP in wp-content/uploads, which should hold only media; almost always a backdoor.' ),
			'upload-config'       => array( 'medium', 'Server configuration in wp-content/uploads that can make the folder run code.' ),
			'double-extension'    => array( 'high', 'PHP file disguised with two extensions, such as image.png.php.' ),
			'hidden-php'          => array( 'medium', 'Hidden PHP file (its name starts with a dot).' ),
			'auto-prepend'        => array( 'high', 'Tells PHP to run a file before or after every request (auto_prepend_file or auto_append_file).' ),
			'php-in-image'        => array( 'high', 'Icon file holding PHP; other code includes it to run the PHP.' ),
			'htaccess-handler'    => array( 'high', '.htaccess makes image or text files run as PHP.' ),
			'htaccess-redirect'   => array( 'high', '.htaccess sends search engine or phone visitors to another site.' ),
			'unexpected-php'      => array( 'medium', 'PHP in wp-content/languages that is not a translation file.' ),
			'too-large'           => array( 'info', 'Larger than 32 MB, so not hashed or read.' ),
			'code:webshell'       => array( 'high', 'Contains a known webshell marker.' ),
			'code:remote-code'    => array( 'high', 'Includes code from a URL.' ),
			'code:var-globals'    => array( 'high', 'Variable names built from hex escapes, typical of webshells.' ),
			'code:eval-decode'    => array( 'medium', 'Runs decoded or decompressed text with eval.' ),
			'code:preg-e'         => array( 'medium', 'preg_replace with the /e modifier, which runs code.' ),
			'code:input-call'     => array( 'medium', 'Calls a function named by request input.' ),
			'code:packed'         => array( 'medium', 'Long encoded text passed to a decoder.' ),
			'code:hex-escapes'    => array( 'medium', 'Long run of hex escapes (obfuscated code).' ),
			'code:image-code'     => array( 'medium', 'Includes an image or text file as PHP.' ),
			'code:chr-chain'      => array( 'medium', 'Builds text from long chr() chains (obfuscation).' ),
			'code:long-line'      => array( 'medium', 'A line over 50,000 characters (packed code).' ),
			'code:goto-maze'      => array( 'medium', 'Dozens of goto jumps, a known obfuscator\'s signature.' ),
			'code:uploader'       => array( 'medium', 'Saves uploaded files, from outside plugins, themes and core.' ),
			'checksum-mismatch'   => array( 'medium', 'Differs from the official release.' ),
			'unexpected-file'     => array( 'medium', 'Not part of the official release.' ),
			'missing-core'        => array( 'medium', 'Core file missing from this install.' ),
			'root-php'            => array( 'low', 'PHP in the main folder that is not part of WordPress.' ),
			'exposed:config-copy' => array( 'high', 'Copy of wp-config.php the web server may serve (database password).' ),
			'exposed:database'    => array( 'high', 'Database dump in a web folder.' ),
			'exposed:secret'      => array( 'high', 'Secrets file in a web folder.' ),
			'exposed:vcs'         => array( 'medium', 'Version control folder (source and history) in a web folder.' ),
			'exposed:tool'        => array( 'medium', 'Database or file manager tool in a web folder.' ),
			'exposed:archive'     => array( 'low', 'Archive or backup in a web folder.' ),
			'exposed:log'         => array( 'low', 'Log file in a web folder.' ),
			'exposed:phpinfo'     => array( 'low', 'phpinfo() page (server details) in a web folder.' ),
		);
		if ( isset( $checks[ $check ] ) ) {
			return $checks[ $check ];
		}
		return 0 === strpos( $check, 'code:' ) ? array( 'medium', 'Code pattern common in backdoors.' ) : array( 'medium', '' );
	}

	/**
	 * Fetches the official checksums the installs need, for scan().
	 *
	 * @param IsHacked_Cli_Lookups $lookups  Lookups.
	 * @param array[]              $installs From IsHacked_Cli_Installs::inventory().
	 * @return array{core: array, plugins: array} key => checksums, or array() when wordpress.org has none.
	 *                                            Missing keys: no answer.
	 */
	public static function official( IsHacked_Cli_Lookups $lookups, array $installs ) {
		$urls = array();
		$keys = array();
		foreach ( $installs as $inv ) {
			if ( preg_match( '/^\d{1,2}\.\d{1,2}(?:\.\d{1,2})?(?:-[\w.]{1,20})?$/', $inv['version'] ) ) {
				$url          = 'https://api.wordpress.org/core/checksums/1.0/?version=' . rawurlencode( $inv['version'] ) . '&locale=' . rawurlencode( $inv['locale'] );
				$urls[ $url ] = self::CORE_TTL;
				$keys[ $url ] = array( 'core', $inv['version'] . '|' . $inv['locale'] );
			}
			foreach ( IsHacked_Cli_Installs::wporg_candidates( $inv ) as $slug => $version ) {
				$url          = 'https://downloads.wordpress.org/plugin-checksums/' . rawurlencode( $slug ) . '/' . rawurlencode( $version ) . '.json';
				$urls[ $url ] = self::PLUGIN_TTL;
				$keys[ $url ] = array( 'plugins', $slug . '|' . $version );
			}
		}
		$out = array(
			'core'    => array(),
			'plugins' => array(),
		);
		foreach ( $lookups->json_many( $urls ) as $url => $answer ) {
			list( $kind, $key ) = $keys[ $url ];
			if ( 404 === $answer[0] ) {
				$out[ $kind ][ $key ] = array();
			} else {
				$out[ $kind ][ $key ] = 'core' === $kind ? IsHacked_Compare::parse_core( $answer[1] ) : IsHacked_Compare::parse_plugin( $answer[1] );
			}
		}
		return $out;
	}

	/**
	 * Scans one install: every file against the rules, core and wordpress.org plugins against their
	 * official checksums, and the web folders for exposed files.
	 *
	 * @param array $inv      From IsHacked_Cli_Installs::inventory().
	 * @param array $official From official().
	 * @param array $opt      within, prune, exclude, all_files, max_scan_bytes, report.
	 * @return array{findings: array[], coverage: array, files: array[]}
	 */
	public static function install( array $inv, array $official, array $opt ) {
		$root           = $inv['path'];
		$within         = isset( $opt['within'] ) ? $opt['within'] : '';
		$cov            = IsHacked_Cli_Fs::coverage();
		$records        = self::walk( $root, $within, $opt, array( 'wp-content/cache' ), $cov );
		$complete       = $cov['complete'] && '' === $within;
		$found          = array();
		$official_files = array();
		$add            = function ( $path, $check, $detail = '', $sha256 = '' ) use ( &$found, $root ) {
			list( $severity, $text ) = self::describe( $check );
			$found[]                 = array(
				'install'  => $root,
				'path'     => $path,
				'check'    => $check,
				'severity' => $severity,
				'detail'   => '' !== $detail ? $detail : $text,
				'sha256'   => $sha256,
			);
		};
		$sha            = function ( $path ) use ( &$records ) {
			return isset( $records[ $path ] ) ? $records[ $path ]['sha256'] : '';
		};

		// Core files.
		$core_key = $inv['version'] . '|' . $inv['locale'];
		$core     = isset( $official['core'][ $core_key ] ) ? $official['core'][ $core_key ] : null;
		if ( $core ) {
			$core_files = array();
			foreach ( $records as $path => $r ) {
				if ( '' === $r['md5'] ) {
					continue;
				}
				// Bundled themes and plugins in wp-content count as official when identical, but may be updated.
				if ( isset( $core[ $path ] ) && $core[ $path ] === $r['md5'] ) {
					$official_files[ $path ] = true;
				}
				if ( 0 !== strpos( $path, 'wp-content/' ) ) {
					$core_files[ $path ] = $r['md5'];
				}
			}
			$cmp                 = IsHacked_Compare::core( $core_files, $core, $complete );
			$cov['coreVerified'] = ! $cmp['modified'] && ! $cmp['unexpected'] && ! $cmp['missing'];
			$cov['missing']      = $cmp['missing'];
			$release             = 'WordPress ' . $inv['version'] . ( 'en_US' === $inv['locale'] ? '' : ' (' . $inv['locale'] . ')' );
			foreach ( $cmp['modified'] as $path ) {
				$add( $path, 'checksum-mismatch', 'Differs from the official ' . $release . '.', $sha( $path ) );
			}
			foreach ( $cmp['unexpected'] as $path ) {
				$add( $path, 'unexpected-file', 'Not part of ' . $release . '.', $sha( $path ) );
			}
			foreach ( $cmp['missing'] as $path ) {
				$add( $path, 'missing-core', 'Missing; part of ' . $release . '.' );
			}
			if ( '' === $within ) {
				foreach ( (array) IsHacked_Cli_Fs::entries( $root ) as $name ) {
					if ( preg_match( '/\.(?:php\d?|phtml|phar)$/i', $name ) && ! isset( $core[ $name ] ) && 'wp-config.php' !== $name && IsHacked_Cli_Fs::is_file( $root . '/' . $name ) ) {
						$add( $name, 'root-php', '', $sha( $name ) );
					}
				}
			}
		} else {
			$cov['notes'][] = null === $core ? 'Official core checksums unavailable (offline or no answer).' : 'wordpress.org has no core checksums for ' . $core_key . '.';
		}

		// Plugins from wordpress.org.
		$verified   = array();
		$unverified = array();
		$candidates = IsHacked_Cli_Installs::wporg_candidates( $inv );
		foreach ( $inv['plugins'] as $slug => $p ) {
			$slug = (string) $slug;
			$key  = $slug . '|' . $p['version'];
			$sums = isset( $candidates[ $slug ], $official['plugins'][ $key ] ) ? $official['plugins'][ $key ] : null;
			if ( ! $sums ) {
				// Single-file plugins that ship with WordPress (Hello Dolly) are verified with core.
				if ( ! isset( $official_files[ 'wp-content/plugins/' . $p['file'] ] ) ) {
					$unverified[] = $slug;
				}
				continue;
			}
			$prefix = 'wp-content/plugins/' . $slug . '/';
			$files  = array();
			foreach ( $records as $path => $r ) {
				if ( 0 === strpos( $path, $prefix ) && '' !== $r['md5'] ) {
					$files[ substr( $path, strlen( $prefix ) ) ] = $r['md5'];
				}
			}
			foreach ( $files as $rel => $md5 ) {
				if ( isset( $sums[ $rel ] ) && in_array( $md5, $sums[ $rel ], true ) ) {
					$official_files[ $prefix . $rel ] = true;
				}
			}
			$cmp     = IsHacked_Compare::plugin( $files, $sums );
			$release = $slug . ' ' . $p['version'] . ' on wordpress.org';
			foreach ( $cmp['modified'] as $rel ) {
				$add( $prefix . $rel, 'checksum-mismatch', 'Differs from ' . $release . '.', $sha( $prefix . $rel ) );
			}
			foreach ( $cmp['unexpected'] as $rel ) {
				$add( $prefix . $rel, 'unexpected-file', 'Not in ' . $release . '.', $sha( $prefix . $rel ) );
			}
			if ( ! $cmp['modified'] && ! $cmp['unexpected'] ) {
				$verified[] = $slug;
			}
		}

		// The rules. A file identical to an official release keeps no code-pattern flag: core and plugins
		// carry test fixtures and data tables that look like attacks.
		foreach ( $records as $path => $r ) {
			foreach ( $r['flags'] as $flag ) {
				if ( 0 === strpos( $flag, 'code:' ) && isset( $official_files[ $path ] ) ) {
					continue;
				}
				$add( $path, $flag, '', $r['sha256'] );
			}
		}

		// Files anyone can download, in the folders the web server serves.
		if ( '' === $within ) {
			foreach ( array( '', 'wp-content/', 'wp-content/uploads/' ) as $prefix ) {
				foreach ( (array) IsHacked_Cli_Fs::entries( $root . '/' . $prefix ) as $name ) {
					$abs = $root . '/' . $prefix . $name;
					if ( is_link( $abs ) ) {
						continue;
					}
					$kind = IsHacked_Rules::exposure_kind( $name, IsHacked_Cli_Fs::is_dir( $abs ) );
					if ( 'maybe-phpinfo' === $kind ) {
						$kind = preg_match( '/\bphpinfo\s*\(/i', (string) IsHacked_Cli_Fs::head( $abs, 65536 ) ) ? 'phpinfo' : '';
					}
					if ( '' !== $kind ) {
						$add( $prefix . $name, 'exposed:' . $kind, '', $sha( $prefix . $name ) );
					}
				}
			}
		}

		$cov['verified']   = $verified;
		$cov['unverified'] = $unverified;
		$cov['themes']     = array_keys( $inv['themes'] );
		$cov['notes']      = array_merge( $inv['notes'], $cov['notes'] );
		if ( '' !== $within ) {
			$cov['notes'][] = 'Only ' . $within . ' was scanned.';
		}
		return array(
			'findings' => $found,
			'coverage' => $cov,
			'files'    => empty( $opt['report'] ) ? array() : self::report_files( $records, $official_files, $found ),
		);
	}

	/**
	 * Scans files outside any install with the rules alone.
	 *
	 * @param string $root Absolute folder.
	 * @param array  $opt  prune, exclude, all_files, max_scan_bytes.
	 * @return array{findings: array[], coverage: array, files: array[]}
	 */
	public static function loose( $root, array $opt ) {
		$cov     = IsHacked_Cli_Fs::coverage();
		$records = self::walk( $root, '', $opt, array(), $cov );
		$found   = array();
		foreach ( $records as $path => $r ) {
			foreach ( $r['flags'] as $flag ) {
				list( $severity, $detail ) = self::describe( $flag );
				$found[]                   = array(
					'install'  => '',
					'path'     => $root . '/' . $path,
					'check'    => $flag,
					'severity' => $severity,
					'detail'   => $detail,
					'sha256'   => $r['sha256'],
				);
			}
		}
		return array(
			'findings' => $found,
			'coverage' => $cov,
			'files'    => array(),
		);
	}

	/**
	 * Hashes and flags the files under a root.
	 *
	 * @param string   $root     Absolute folder.
	 * @param string   $within   Path below the root to limit the walk to.
	 * @param array    $opt      prune, exclude, all_files, max_scan_bytes.
	 * @param string[] $skip_rel Folders below the root left out.
	 * @param array    $cov      Coverage, updated in place.
	 * @return array<string, array{md5: string, sha256: string, size: int, mtime: int, flags: string[]}>
	 */
	private static function walk( $root, $within, array $opt, array $skip_rel, array &$cov ) {
		$records = array();
		$all     = ! empty( $opt['all_files'] );
		$max     = isset( $opt['max_scan_bytes'] ) ? (int) $opt['max_scan_bytes'] : 1048576;
		IsHacked_Cli_Fs::walk(
			$root,
			$within,
			array(
				'prune'    => isset( $opt['prune'] ) ? $opt['prune'] : array(),
				'skip_rel' => $skip_rel,
				'exclude'  => isset( $opt['exclude'] ) ? $opt['exclude'] : array(),
			),
			function ( $rel, $abs ) use ( &$records, &$cov, $all, $max ) {
				$name = basename( $rel );
				if ( ! $all && ! preg_match( IsHacked_Rules::CODE_RE, $name ) && ! preg_match( IsHacked_Rules::CONFIG_RE, $name ) ) {
					return;
				}
				$d = IsHacked_Cli_Fs::digest( $abs, $max );
				if ( null === $d ) {
					IsHacked_Cli_Fs::skip( $cov, 'unreadable-file', $rel );
					return;
				}
				++$cov['files'];
				$records[ $rel ] = array(
					'md5'    => $d['md5'],
					'sha256' => $d['sha256'],
					'size'   => $d['size'],
					'mtime'  => $d['mtime'],
					'flags'  => array_merge( '' === $d['md5'] ? array( 'too-large' ) : array(), IsHacked_Rules::flags( $rel, $d['text'] ) ),
				);
			},
			$cov
		);
		return $records;
	}

	/**
	 * The files a report sends: flagged ones (by the rules, or for differing from an official release)
	 * and tracked configuration, in the plugin's row format: path, size, mtime, md5, sha256, flags.
	 *
	 * @param array   $records        From walk().
	 * @param array   $official_files Paths identical to an official release.
	 * @param array[] $found          Findings, for the comparison flags.
	 * @return array[]
	 */
	private static function report_files( array $records, array $official_files, array $found ) {
		$compared = array();
		foreach ( $found as $f ) {
			if ( 'checksum-mismatch' === $f['check'] || 'unexpected-file' === $f['check'] ) {
				$compared[ $f['path'] ][] = $f['check'];
			}
		}
		$flagged = array();
		$tracked = array();
		foreach ( $records as $path => $r ) {
			$path  = (string) $path;
			$flags = isset( $compared[ $path ] ) ? $compared[ $path ] : array();
			foreach ( $r['flags'] as $flag ) {
				if ( ! ( 0 === strpos( $flag, 'code:' ) && isset( $official_files[ $path ] ) ) ) {
					$flags[] = $flag;
				}
			}
			$row = array( $path, $r['size'], $r['mtime'], $r['md5'], $r['sha256'], array_slice( $flags, 0, 10 ) );
			if ( $flags ) {
				$flagged[] = $row;
			} elseif ( preg_match( self::TRACKED_RE, $path ) ) {
				$tracked[] = $row;
			}
		}
		return array_merge( array_slice( $flagged, 0, 2000 ), array_slice( $tracked, 0, 500 ) );
	}

	/**
	 * Known vulnerabilities in core, plugins and themes, and wordpress.org plugins that were closed.
	 *
	 * @param IsHacked_Cli_Lookups $lookups  Lookups.
	 * @param array[]              $installs From IsHacked_Cli_Installs::inventory().
	 * @return array{rows: array[], unchecked: array<string, string[]>} Rows, and per install what got no answer.
	 */
	public static function vulns( IsHacked_Cli_Lookups $lookups, array $installs ) {
		$urls = array();
		foreach ( $installs as $inv ) {
			foreach ( self::components( $inv ) as $c ) {
				if ( '' !== $c['url'] ) {
					$urls[ $c['url'] ] = self::VULN_TTL;
				}
				if ( '' !== $c['info'] ) {
					$urls[ $c['info'] ] = self::INFO_TTL;
				}
			}
		}
		$answers   = $lookups->json_many( $urls );
		$rows      = array();
		$unchecked = array();
		foreach ( $installs as $inv ) {
			foreach ( self::components( $inv ) as $c ) {
				if ( '' === $c['url'] || ! isset( $answers[ $c['url'] ] ) ) {
					$unchecked[ $inv['path'] ][] = $c['type'] . ' ' . $c['slug'];
					continue;
				}
				$data = IsHacked_Advisories::parse( $c['type'], $answers[ $c['url'] ][1] );
				foreach ( IsHacked_Advisories::affecting( $data, 'core' === $c['type'] ? '' : $c['version'] ) as $v ) {
					$links  = IsHacked_Advisories::links_of( $v );
					$rows[] = array(
						'install'  => $inv['path'],
						'type'     => $c['type'],
						'slug'     => $c['slug'],
						'version'  => $c['version'],
						'id'       => $v['id'],
						'title'    => $v['name'],
						'cvss'     => $v['score'] ? (string) $v['score'] : '',
						'severity' => IsHacked_Advisories::level( $v['score'] ),
						'fixed_in' => IsHacked_Advisories::fixed_in( $v ),
						'unfixed'  => $v['unfixed'] ? 'yes' : 'no',
						'link'     => $links ? $links[0][1] : '',
					);
				}
				$info = '' !== $c['info'] && isset( $answers[ $c['info'] ] ) ? IsHacked_Advisories::parse_info( $answers[ $c['info'] ][0], $answers[ $c['info'] ][1] ) : null;
				if ( $data['closed'] || ( $info && $info['found'] && $info['closed'] ) ) {
					$reason = $info && '' !== $info['reason'] ? $info['reason'] : $data['reason'];
					$rows[] = array(
						'install'  => $inv['path'],
						'type'     => $c['type'],
						'slug'     => $c['slug'],
						'version'  => $c['version'],
						'id'       => 'closed',
						'title'    => 'Closed on wordpress.org' . ( '' !== $reason ? ': ' . $reason : '' ) . '; it gets no more fixes.',
						'cvss'     => '',
						'severity' => 'medium',
						'fixed_in' => '',
						'unfixed'  => 'yes',
						'link'     => 'https://wordpress.org/plugins/' . rawurlencode( $c['slug'] ) . '/',
					);
				}
			}
		}
		return array(
			'rows'      => $rows,
			'unchecked' => $unchecked,
		);
	}

	/**
	 * What vuln-scan looks up for an install: core, plugins and themes, each with its wpvulnerability.net
	 * address and, for plugins from wordpress.org, its plugin API address.
	 *
	 * @param array $inv From IsHacked_Cli_Installs::inventory().
	 * @return array[]
	 */
	private static function components( array $inv ) {
		$out   = array();
		$wporg = IsHacked_Cli_Installs::wporg_candidates( $inv );
		if ( '' !== $inv['version'] ) {
			$out[] = array(
				'type'    => 'core',
				'slug'    => 'wordpress',
				'version' => $inv['version'],
				'url'     => IsHacked_Advisories::url( 'core', $inv['version'] ),
				'info'    => '',
			);
		}
		foreach ( $inv['plugins'] as $slug => $p ) {
			$out[] = array(
				'type'    => 'plugin',
				'slug'    => (string) $slug,
				'version' => $p['version'],
				'url'     => '' === $p['version'] ? '' : IsHacked_Advisories::url( 'plugin', (string) $slug ),
				'info'    => isset( $wporg[ (string) $slug ] ) ? IsHacked_Advisories::info_url( (string) $slug ) : '',
			);
		}
		foreach ( $inv['themes'] as $slug => $t ) {
			$out[] = array(
				'type'    => 'theme',
				'slug'    => (string) $slug,
				'version' => $t['version'],
				'url'     => '' === $t['version'] ? '' : IsHacked_Advisories::url( 'theme', (string) $slug ),
				'info'    => '',
			);
		}
		return $out;
	}
}
<?php
/**
 * Places outside WordPress where malware hides and comes back from: the user's crontab, executables
 * in temporary folders, processes running from them, SSH keys, and PHP settings in parent folders.
 * Each check runs only where the server allows it (shell functions disabled, no /proc) and says so
 * when it couldn't.
 *
 * @package IsHacked
 */

/**
 * Server facts.
 */
final class IsHacked_Cli_Server {

	/** Crontab lines that fetch, decode or run code from unusual places. */
	const CRON_RE = '#\b(?:curl|wget|fetch|lwp-download)\b[^\n]*\|\s*(?:ba|z|da)?sh\b|\bbase64\s+(?:-d|--decode)\b|\bphp\s+-r\b|\beval\b|/dev/shm/|/tmp/[^\s]*\.(?:php|sh|pl|py)\b|\bnohup\b[^\n]*/tmp/|\bpython[23]?\s+-c\b|\bperl\s+-e\b#i';

	/** Temporary folders. */
	const TMP = array( '/tmp', '/var/tmp', '/dev/shm' );

	/** Most items listed per check. */
	const MAX = 100;

	/**
	 * All server facts.
	 *
	 * @param string[] $installs Install paths (for parent-folder PHP settings).
	 * @return array
	 */
	public static function facts( array $installs ) {
		return array(
			'user'      => self::user(),
			'crontab'   => self::crontab(),
			'tmp'       => self::tmp(),
			'processes' => self::processes(),
			'sshKeys'   => self::ssh_keys(),
			'parents'   => self::parents( $installs ),
		);
	}

	/**
	 * The user running the scan.
	 *
	 * @return string
	 */
	private static function user() {
		if ( function_exists( 'posix_getpwuid' ) && function_exists( 'posix_geteuid' ) ) {
			$pw = posix_getpwuid( posix_geteuid() );
			if ( is_array( $pw ) ) {
				return (string) $pw['name'];
			}
		}
		return (string) get_current_user();
	}

	/**
	 * The user's crontab: a hash per line, and the lines that look like malware in full.
	 *
	 * @return array{available: bool, lines: array[]}
	 */
	public static function crontab() {
		$text = self::command( array( 'crontab', '-l' ) );
		if ( null === $text ) {
			return array(
				'available' => false,
				'lines'     => array(),
			);
		}
		return array(
			'available' => true,
			'lines'     => self::cron_lines( $text ),
		);
	}

	/**
	 * Crontab lines, comments left out.
	 *
	 * @param string $text Crontab.
	 * @return array[] Each: hash, and the line when it looks suspicious ('' otherwise).
	 */
	public static function cron_lines( $text ) {
		$out = array();
		foreach ( preg_split( '/\r?\n/', $text ) as $line ) {
			$line = trim( $line );
			if ( '' === $line || '#' === $line[0] || count( $out ) >= self::MAX ) {
				continue;
			}
			$out[] = array(
				'hash'       => hash( 'sha256', $line ),
				'suspicious' => preg_match( self::CRON_RE, $line ) ? substr( $line, 0, 500 ) : '',
			);
		}
		return $out;
	}

	/**
	 * Executables, scripts and PHP in temporary folders owned by this user.
	 *
	 * @return array{available: bool, files: array[]}
	 */
	public static function tmp() {
		$uid   = function_exists( 'posix_geteuid' ) ? posix_geteuid() : null;
		$files = array();
		$any   = false;
		foreach ( self::TMP as $dir ) {
			$names = IsHacked_Cli_Fs::entries( $dir );
			if ( null === $names ) {
				continue;
			}
			$any = true;
			foreach ( $names as $name ) {
				$abs = $dir . '/' . $name;
				$st  = @lstat( $abs );
				if ( ! $st || 0100000 !== ( $st['mode'] & 0170000 ) || ( null !== $uid && $st['uid'] !== $uid ) || count( $files ) >= self::MAX ) {
					continue;
				}
				$kind = self::kind( $abs, $name, (int) $st['mode'] );
				if ( '' !== $kind ) {
					$d       = IsHacked_Cli_Fs::digest( $abs, 0 );
					$files[] = array(
						'path'   => $abs,
						'kind'   => $kind,
						'sha256' => $d ? $d['sha256'] : '',
						'mtime'  => (int) $st['mtime'],
					);
				}
			}
		}
		return array(
			'available' => $any,
			'files'     => $files,
		);
	}

	/**
	 * What a temporary file is: php, elf (a program), script, or '' for neither.
	 *
	 * @param string $abs  Path.
	 * @param string $name Name.
	 * @param int    $mode lstat mode.
	 * @return string
	 */
	public static function kind( $abs, $name, $mode ) {
		$head = (string) IsHacked_Cli_Fs::head( $abs, 512 );
		if ( preg_match( IsHacked_Rules::PHP_RE, $name ) || false !== strpos( $head, '<?php' ) ) {
			return 'php';
		}
		if ( 0 === strpos( $head, "\x7fELF" ) ) {
			return 'elf';
		}
		return ( $mode & 0111 ) && 0 === strpos( $head, '#!' ) ? 'script' : '';
	}

	/**
	 * This user's processes running from temporary or upload folders, or from a deleted program (Linux).
	 *
	 * @return array{available: bool, suspicious: array[]}
	 */
	public static function processes() {
		$pids = IsHacked_Cli_Fs::entries( '/proc' );
		if ( null === $pids || ! is_dir( '/proc/self' ) ) {
			return array(
				'available'  => false,
				'suspicious' => array(),
			);
		}
		$uid = function_exists( 'posix_geteuid' ) ? posix_geteuid() : null;
		$own = getmypid();
		$out = array();
		foreach ( $pids as $pid ) {
			if ( ! ctype_digit( $pid ) || (int) $pid === $own || count( $out ) >= self::MAX ) {
				continue;
			}
			$st = @stat( '/proc/' . $pid );
			if ( ! $st || ( null !== $uid && $st['uid'] !== $uid ) ) {
				continue;
			}
			$cmd = trim( str_replace( "\0", ' ', (string) @file_get_contents( '/proc/' . $pid . '/cmdline' ) ) );
			$exe = (string) @readlink( '/proc/' . $pid . '/exe' );
			if ( self::suspicious_process( $cmd, $exe ) ) {
				$out[] = array(
					'pid' => (int) $pid,
					'cmd' => substr( $cmd, 0, 300 ),
					'exe' => substr( $exe, 0, 300 ),
				);
			}
		}
		return array(
			'available'  => true,
			'suspicious' => $out,
		);
	}

	/**
	 * Whether a process runs from a temporary or upload folder, or from a program deleted after it
	 * started (how miners and backdoors hide).
	 *
	 * @param string $cmd Command line.
	 * @param string $exe Program path.
	 * @return bool
	 */
	public static function suspicious_process( $cmd, $exe ) {
		if ( '' === $cmd && '' === $exe ) {
			return false;
		}
		if ( preg_match( '# \(deleted\)$#', $exe ) && ! preg_match( '#^/(?:usr|bin|sbin|lib|opt)/#', $exe ) ) {
			return true;
		}
		return (bool) preg_match( '#(?:^|[\s=])(?:/tmp/|/var/tmp/|/dev/shm/)|/wp-content/uploads/#', $cmd . ' ' . $exe );
	}

	/**
	 * Fingerprints of the user's SSH authorized keys.
	 *
	 * @return array{available: bool, fingerprints: string[]}
	 */
	public static function ssh_keys() {
		$home = getenv( 'HOME' );
		if ( ! $home && function_exists( 'posix_getpwuid' ) && function_exists( 'posix_geteuid' ) ) {
			$pw   = posix_getpwuid( posix_geteuid() );
			$home = is_array( $pw ) ? $pw['dir'] : '';
		}
		$text = $home ? IsHacked_Cli_Fs::head( $home . '/.ssh/authorized_keys', 1048576 ) : null;
		if ( null === $text ) {
			return array(
				'available'    => (bool) $home && ! file_exists( $home . '/.ssh/authorized_keys' ),
				'fingerprints' => array(),
			);
		}
		return array(
			'available'    => true,
			'fingerprints' => self::fingerprints( $text ),
		);
	}

	/**
	 * SHA-256 fingerprints (as ssh-keygen -l shows them) of the keys in an authorized_keys file.
	 *
	 * @param string $text File content.
	 * @return string[]
	 */
	public static function fingerprints( $text ) {
		$out = array();
		foreach ( preg_split( '/\r?\n/', $text ) as $line ) {
			if ( preg_match( '#(?:^|\s)(?:ssh-(?:rsa|dss|ed25519)|ecdsa-sha2-nistp\d+|sk-[\w@.-]+)\s+([A-Za-z0-9+/]+={0,2})#', $line, $m ) ) {
				$raw = base64_decode( $m[1], true );
				if ( false !== $raw ) {
					$out[] = 'SHA256:' . rtrim( base64_encode( hash( 'sha256', $raw, true ) ), '=' );
				}
			}
		}
		return array_slice( array_values( array_unique( $out ) ), 0, self::MAX );
	}

	/**
	 * PHP settings in the folders above each install (.user.ini, php.ini, .htaccess) that run a file
	 * before every request, up to the user's home or 5 levels.
	 *
	 * @param string[] $installs Install paths.
	 * @return array[] Each: path, sha256, flags.
	 */
	public static function parents( array $installs ) {
		$seen = array();
		$out  = array();
		foreach ( $installs as $install ) {
			$dir = dirname( $install );
			for ( $i = 0; $i < 5 && '/' !== $dir && '.' !== $dir; ++$i, $dir = dirname( $dir ) ) {
				foreach ( array( '.user.ini', 'php.ini', '.htaccess' ) as $name ) {
					$abs = $dir . '/' . $name;
					if ( isset( $seen[ $abs ] ) ) {
						continue;
					}
					$seen[ $abs ] = true;
					$d            = IsHacked_Cli_Fs::is_file( $abs ) ? IsHacked_Cli_Fs::digest( $abs, 1048576 ) : null;
					if ( $d && null !== $d['text'] ) {
						$flags = IsHacked_Rules::flags( $name, $d['text'] );
						if ( $flags ) {
							$out[] = array(
								'path'   => $abs,
								'sha256' => $d['sha256'],
								'flags'  => $flags,
							);
						}
					}
				}
			}
		}
		return $out;
	}

	/**
	 * Runs a program (no shell) and returns its output, or null when the server doesn't allow it or it
	 * fails. "no crontab for user" counts as an empty crontab.
	 *
	 * @param string[] $argv Program and arguments.
	 * @return string|null
	 */
	private static function command( array $argv ) {
		$disabled = array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) );
		if ( ! function_exists( 'proc_open' ) || in_array( 'proc_open', $disabled, true ) ) {
			return null;
		}
		// An argument list (PHP 7.4+) runs the program directly, without a shell.
		$p = @proc_open(
			$argv,
			array(
				0 => array( 'file', '/dev/null', 'r' ),
				1 => array( 'pipe', 'w' ),
				2 => array( 'pipe', 'w' ),
			),
			$pipes
		);
		if ( ! is_resource( $p ) ) {
			return null;
		}
		$out = (string) stream_get_contents( $pipes[1], 1048576 );
		$err = (string) stream_get_contents( $pipes[2], 65536 );
		fclose( $pipes[1] );
		fclose( $pipes[2] );
		$code = proc_close( $p );
		if ( 0 !== $code ) {
			return preg_match( '/no crontab for/i', $err ) ? '' : null;
		}
		return $out;
	}

	/**
	 * Local findings from the server facts, for `scan --server`.
	 *
	 * @param array $f From facts().
	 * @return array[] Rows: path, check, severity, detail, sha256.
	 */
	public static function findings( array $f ) {
		$rows = array();
		foreach ( $f['crontab']['lines'] as $l ) {
			if ( '' !== $l['suspicious'] ) {
				$rows[] = array( 'crontab', 'server-cron', 'high', 'Crontab line that downloads, decodes or runs code from a temporary folder: ' . $l['suspicious'], '' );
			}
		}
		foreach ( $f['tmp']['files'] as $t ) {
			$rows[] = array( $t['path'], 'server-tmp-exec', 'medium', 'Temporary folder holds ' . ( 'elf' === $t['kind'] ? 'a program' : ( 'php' === $t['kind'] ? 'PHP' : 'a script' ) ) . ' owned by this user; malware drops itself there.', $t['sha256'] );
		}
		foreach ( $f['processes']['suspicious'] as $p ) {
			$rows[] = array( 'process ' . $p['pid'], 'server-process', 'high', 'Process running from a temporary or upload folder, or from a deleted program: ' . ( '' !== $p['cmd'] ? $p['cmd'] : $p['exe'] ), '' );
		}
		foreach ( $f['parents'] as $p ) {
			foreach ( $p['flags'] as $flag ) {
				list( $severity, $detail ) = IsHacked_Cli_Scan::describe( $flag );
				$rows[]                    = array( $p['path'], $flag, $severity, $detail, $p['sha256'] );
			}
		}
		$out = array();
		foreach ( $rows as $r ) {
			$out[] = array(
				'install'  => '',
				'path'     => $r[0],
				'check'    => $r[1],
				'severity' => $r[2],
				'detail'   => $r[3],
				'sha256'   => $r[4],
			);
		}
		return $out;
	}
}
<?php
/**
 * Commands, options and exit codes.
 *
 * Exit codes: 0 nothing at or above --fail-on, 1 findings at or above it, 2 an error, 3 the scan was
 * incomplete (unreadable files, lookups without an answer) and --strict was given. 1 wins over 3.
 *
 * @package IsHacked
 */

/**
 * The command line.
 */
final class IsHacked_Cli {

	const EXIT_CLEAN      = 0;
	const EXIT_FINDINGS   = 1;
	const EXIT_ERROR      = 2;
	const EXIT_INCOMPLETE = 3;

	/** Options that take a value. */
	const VALUED = array( 'output', 'output-file', 'columns', 'fail-on', 'cache-dir', 'workers', 'max-depth', 'exclude', 'max-scan-bytes', 'config', 'name', 'api' );

	/** Options that don't. */
	const FLAGS = array( 'no-header', 'strict', 'offline', 'all-files', 'quiet', 'help', 'yes', 'db', 'server' );

	/** Commands of the connected mode (IsHacked_Cli_Companion). */
	const COMPANION = array( 'connect', 'report', 'disconnect', 'status' );

	/** Columns of each command, in their default order. */
	const COLUMNS = array(
		'scan'      => array( 'install', 'path', 'check', 'severity', 'detail', 'sha256' ),
		'vuln-scan' => array( 'install', 'type', 'slug', 'version', 'id', 'title', 'cvss', 'severity', 'fixed_in', 'unfixed', 'link' ),
		'find'      => array( 'install', 'wordpress', 'locale', 'multisite', 'plugins', 'themes', 'mu_plugins', 'dropins' ),
	);

	/**
	 * Test stand-in for the network, passed to IsHacked_Cli_Lookups.
	 *
	 * @var callable|null
	 */
	public static $transport = null;

	/**
	 * How this program was started (the phar or ishacked.php), for the cron line connect prints.
	 *
	 * @var string
	 */
	public static $script = '';

	/**
	 * Runs a command line.
	 *
	 * @param string[] $argv Arguments, the program's name first.
	 * @return int Exit code.
	 */
	public static function main( array $argv ) {
		$command      = isset( $argv[1] ) ? $argv[1] : 'help';
		self::$script = isset( $argv[0] ) ? (string) realpath( $argv[0] ) : '';
		try {
			$args = self::parse( array_slice( $argv, 2 ) );
		} catch ( InvalidArgumentException $e ) {
			return self::fail( $e->getMessage() );
		}
		if ( in_array( $command, array( 'help', '--help', '-h' ), true ) || isset( $args['opts']['help'] ) ) {
			fwrite( STDOUT, self::help() );
			return self::EXIT_CLEAN;
		}
		if ( in_array( $command, array( 'version', '--version' ), true ) ) {
			fwrite( STDOUT, 'ishacked ' . ISHACKED_CLI_VERSION . ' (PHP ' . PHP_VERSION . ")\n" );
			return self::EXIT_CLEAN;
		}
		if ( ! isset( self::COLUMNS[ $command ] ) && ! in_array( $command, self::COMPANION, true ) ) {
			return self::fail( "Unknown command \"$command\". Run \"ishacked help\"." );
		}
		self::raise_memory_limit();
		try {
			$code = 'connect' === $command ? array_shift( $args['paths'] ) : '';
			if ( null === $code ) {
				return self::fail( 'Usage: ishacked connect CODE [folders...]' );
			}
			$opt = self::options( isset( self::COLUMNS[ $command ] ) ? $command : 'scan', $args );
			switch ( $command ) {
				case 'connect':
					return IsHacked_Cli_Companion::connect( array_merge( $opt, array( 'code' => $code ) ) );
				case 'report':
					return IsHacked_Cli_Companion::report( $opt );
				case 'disconnect':
					return IsHacked_Cli_Companion::disconnect( $opt );
				case 'status':
					return IsHacked_Cli_Companion::status( $opt );
				default:
					return self::run( $command, $opt );
			}
		} catch ( InvalidArgumentException $e ) {
			return self::fail( $e->getMessage() );
		}
	}

	/**
	 * Splits options from paths.
	 *
	 * @param string[] $args Arguments after the command.
	 * @return array{opts: array, paths: string[]}
	 * @throws InvalidArgumentException On an unknown option or a missing value.
	 */
	public static function parse( array $args ) {
		$opts  = array( 'exclude' => array() );
		$paths = array();
		$rest  = false;
		for ( $i = 0, $n = count( $args ); $i < $n; ++$i ) {
			$a = $args[ $i ];
			if ( $rest || '' === $a || '-' !== $a[0] ) {
				$paths[] = $a;
				continue;
			}
			if ( '--' === $a ) {
				$rest = true;
				continue;
			}
			if ( '-h' === $a ) {
				$opts['help'] = true;
				continue;
			}
			$eq    = strpos( $a, '=' );
			$name  = false === $eq ? substr( $a, 2 ) : substr( $a, 2, $eq - 2 ); // PHP 7 reads a null length as 0.
			$value = false === $eq ? null : substr( $a, $eq + 1 );
			if ( 0 !== strpos( $a, '--' ) || ( ! in_array( $name, self::VALUED, true ) && ! in_array( $name, self::FLAGS, true ) ) ) {
				throw new InvalidArgumentException( "Unknown option $a. Run \"ishacked help\"." );
			}
			if ( in_array( $name, self::FLAGS, true ) ) {
				if ( null !== $value ) {
					throw new InvalidArgumentException( "--$name takes no value." );
				}
				$opts[ $name ] = true;
				continue;
			}
			if ( null === $value ) {
				if ( ++$i >= $n ) {
					throw new InvalidArgumentException( "--$name needs a value." );
				}
				$value = $args[ $i ];
			}
			if ( 'exclude' === $name ) {
				$opts['exclude'][] = $value;
			} else {
				$opts[ $name ] = $value;
			}
		}
		return array(
			'opts'  => $opts,
			'paths' => $paths,
		);
	}

	/**
	 * Checked options for a command.
	 *
	 * @param string $command Command.
	 * @param array  $args    From parse().
	 * @return array
	 * @throws InvalidArgumentException On a bad value.
	 */
	private static function options( $command, array $args ) {
		$o      = $args['opts'];
		$tty    = function_exists( 'stream_isatty' ) && stream_isatty( STDOUT ) && ! isset( $o['output-file'] );
		$format = isset( $o['output'] ) ? $o['output'] : ( $tty ? 'human' : 'tsv' );
		if ( ! in_array( $format, IsHacked_Cli_Output::FORMATS, true ) ) {
			throw new InvalidArgumentException( '--output must be one of: ' . implode( ', ', IsHacked_Cli_Output::FORMATS ) . '.' );
		}
		$columns = self::COLUMNS[ $command ];
		if ( isset( $o['columns'] ) ) {
			$columns = array_values( array_filter( array_map( 'trim', explode( ',', $o['columns'] ) ) ) );
			$unknown = array_diff( $columns, self::COLUMNS[ $command ] );
			if ( ! $columns || $unknown ) {
				throw new InvalidArgumentException( '--columns for ' . $command . ' can be: ' . implode( ', ', self::COLUMNS[ $command ] ) . '.' );
			}
		}
		$fail_on = isset( $o['fail-on'] ) ? $o['fail-on'] : 'medium';
		if ( ! isset( IsHacked_Cli_Scan::RANK[ $fail_on ] ) || 'unrated' === $fail_on ) {
			throw new InvalidArgumentException( '--fail-on must be one of: critical, high, medium, low, info.' );
		}
		$paths = array();
		foreach ( $args['paths'] ? $args['paths'] : array( '.' ) as $p ) {
			$real = realpath( $p );
			if ( false === $real || ! is_dir( $real ) ) {
				throw new InvalidArgumentException( "Not a folder: $p" );
			}
			$paths[] = rtrim( $real, '/' );
		}
		return array(
			'paths'          => array_values( array_unique( $paths ) ),
			'format'         => $format,
			'columns'        => $columns,
			'header'         => ! isset( $o['no-header'] ),
			'output_file'    => isset( $o['output-file'] ) ? $o['output-file'] : '',
			'fail_on'        => $fail_on,
			'strict'         => isset( $o['strict'] ),
			'offline'        => isset( $o['offline'] ),
			'all_files'      => isset( $o['all-files'] ),
			'quiet'          => isset( $o['quiet'] ),
			'cache_dir'      => isset( $o['cache-dir'] ) ? $o['cache-dir'] : '',
			'workers'        => self::number( $o, 'workers', IsHacked_Cli_Pool::default_workers(), 1, IsHacked_Cli_Pool::MAX ),
			'max_depth'      => self::number( $o, 'max-depth', 6, 0, 40 ),
			'max_scan_bytes' => self::number( $o, 'max-scan-bytes', 1048576, 0, IsHacked_Cli_Fs::MAX_HASH_BYTES ),
			'exclude'        => $o['exclude'],
			'paths_given'    => (bool) $args['paths'],
			'db'             => isset( $o['db'] ),
			'server'         => isset( $o['server'] ),
			'config'         => isset( $o['config'] ) ? $o['config'] : '',
			'name'           => isset( $o['name'] ) ? $o['name'] : '',
			'api'            => isset( $o['api'] ) ? $o['api'] : '',
			'yes'            => isset( $o['yes'] ),
		);
	}

	/**
	 * A whole number option within limits.
	 *
	 * @param array  $o       Options.
	 * @param string $name    Name.
	 * @param int    $fallback Default.
	 * @param int    $min     Least.
	 * @param int    $max     Most.
	 * @return int
	 * @throws InvalidArgumentException When it isn't one.
	 */
	private static function number( array $o, $name, $fallback, $min, $max ) {
		if ( ! isset( $o[ $name ] ) ) {
			return $fallback;
		}
		if ( ! preg_match( '/^\d{1,10}$/', $o[ $name ] ) || (int) $o[ $name ] < $min || (int) $o[ $name ] > $max ) {
			throw new InvalidArgumentException( "--$name must be a whole number from $min to $max." );
		}
		return (int) $o[ $name ];
	}

	/**
	 * Runs a command.
	 *
	 * @param string $command Command.
	 * @param array  $opt     From options().
	 * @return int Exit code.
	 * @throws InvalidArgumentException When the output file can't be written.
	 */
	private static function run( $command, array $opt ) {
		$started = microtime( true );
		$plan    = self::plan( $opt );
		$lookups = new IsHacked_Cli_Lookups( $opt['cache_dir'], $opt['offline'], self::$transport );
		$invs    = array();
		foreach ( $plan['installs'] as $path => $within ) {
			$invs[ $path ] = IsHacked_Cli_Installs::inventory( $path );
		}
		$meta       = array(
			'tool'     => 'ishacked',
			'version'  => ISHACKED_CLI_VERSION,
			'command'  => $command,
			'started'  => gmdate( 'c', (int) $started ),
			'paths'    => $opt['paths'],
			'installs' => array(),
		);
		$incomplete = ! $plan['coverage']['complete'];
		$notes      = array();
		if ( 'find' === $command ) {
			$rows = array();
			foreach ( $invs as $inv ) {
				$rows[] = array(
					'install'    => $inv['path'],
					'wordpress'  => $inv['version'],
					'locale'     => $inv['locale'],
					'multisite'  => $inv['multisite'] ? 'yes' : 'no',
					'plugins'    => count( $inv['plugins'] ),
					'themes'     => count( $inv['themes'] ),
					'mu_plugins' => count( $inv['mu'] ),
					'dropins'    => count( $inv['dropins'] ),
				);
			}
			$summary = array( sprintf( 'Found %d WordPress %s.', count( $rows ), 1 === count( $rows ) ? 'install' : 'installs' ) );
		} elseif ( 'vuln-scan' === $command ) {
			$result = IsHacked_Cli_Scan::vulns( $lookups, $invs );
			$rows   = self::sorted( $result['rows'], array_keys( $invs ) );
			foreach ( $result['unchecked'] as $path => $what ) {
				$notes[] = self::count_text( count( $what ), 'component' ) . ' not checked in ' . $path . ' (no version or no answer): ' . implode( ', ', array_slice( $what, 0, 10 ) ) . ( count( $what ) > 10 ? ', â€¦' : '' );
			}
			$summary = array( sprintf( 'Checked %s for known vulnerabilities.', self::count_text( count( $invs ), 'WordPress install' ) ) );
		} else {
			$official = IsHacked_Cli_Scan::official( $lookups, $invs );
			$units    = array();
			foreach ( $plan['installs'] as $path => $within ) {
				$units[] = array( 'install', $path, $within );
			}
			foreach ( $plan['loose'] as $path ) {
				$units[] = array( 'loose', $path, '' );
			}
			$progress = ! $opt['quiet'] && function_exists( 'stream_isatty' ) && stream_isatty( STDERR );
			$total    = count( $units );
			$finished = 0;
			$results  = IsHacked_Cli_Pool::run(
				$units,
				$opt['workers'],
				function ( $unit ) use ( $invs, $official, $opt, $plan ) {
					$scan = array(
						'within'         => $unit[2],
						'prune'          => self::inside( $unit[1], array_keys( $plan['installs'] ) ),
						'exclude'        => $opt['exclude'],
						'all_files'      => $opt['all_files'],
						'max_scan_bytes' => $opt['max_scan_bytes'],
					);
					return 'install' === $unit[0] ? IsHacked_Cli_Scan::install( $invs[ $unit[1] ], $official, $scan ) : IsHacked_Cli_Scan::loose( $unit[1], $scan );
				},
				function ( $i, $result ) use ( $progress, $total, &$finished, $units ) {
					++$finished;
					if ( $progress ) {
						fwrite( STDERR, sprintf( "[%d/%d] %s: %s files\n", $finished, $total, IsHacked_Cli_Output::clean( $units[ $i ][1] ), number_format( $result['coverage']['files'] ) ) );
					}
				}
			);
			$rows     = array();
			$files    = 0;
			foreach ( $results as $i => $result ) {
				$rows   = array_merge( $rows, $result['findings'] );
				$files += $result['coverage']['files'];
				if ( ! $result['coverage']['complete'] ) {
					$incomplete = true;
					foreach ( $result['coverage']['skipped'] as $reason => $s ) {
						$notes[] = $units[ $i ][1] . ': ' . self::count_text( $s['count'], 'item' ) . ' left out (' . $reason . '), such as ' . implode( ', ', $s['examples'] );
					}
				}
				if ( 'install' === $units[ $i ][0] ) {
					$meta['installs'][] = self::install_meta( $invs[ $units[ $i ][1] ], $result['coverage'] );
					if ( $result['coverage']['unverified'] ) {
						$notes[] = $units[ $i ][1] . ': ' . self::count_text( count( $result['coverage']['unverified'] ), 'plugin' ) . ' without official checksums (premium, custom or not on wordpress.org): ' . implode( ', ', array_slice( $result['coverage']['unverified'], 0, 10 ) ) . ( count( $result['coverage']['unverified'] ) > 10 ? ', â€¦' : '' );
					}
					foreach ( $result['coverage']['notes'] as $note ) {
						$notes[] = $units[ $i ][1] . ': ' . $note;
					}
				} else {
					$meta['outside'][] = array(
						'path'     => $units[ $i ][1],
						'coverage' => $result['coverage'],
					);
				}
			}
			if ( $opt['db'] ) {
				foreach ( $invs as $path => $inv ) {
					$db = IsHacked_Cli_Database::read( $path );
					if ( '' !== $db['note'] ) {
						$notes[] = $path . ': ' . $db['note'];
					}
					$rows = array_merge( $rows, self::database_rows( $path, $db['facts'] ) );
				}
			}
			if ( $opt['server'] ) {
				$server = IsHacked_Cli_Server::facts( array_keys( $invs ) );
				$rows   = array_merge( $rows, IsHacked_Cli_Server::findings( $server ) );
				foreach ( array( 'crontab', 'tmp', 'processes' ) as $what ) {
					if ( ! $server[ $what ]['available'] ) {
						$notes[] = 'Server: ' . $what . ' not checked (not allowed or not available here).';
					}
				}
			}
			$rows    = self::sorted( $rows, array_merge( array_keys( $plan['installs'] ), array( '' ) ) );
			$summary = array(
				sprintf(
					'Scanned %s in %s%s in %ss.',
					self::count_text( $files, 'file' ),
					self::count_text( count( $plan['installs'] ), 'WordPress install' ),
					$plan['loose'] ? ' and the files outside them' : '',
					number_format( microtime( true ) - $started, 1 )
				),
			);
		}
		if ( 'scan' !== $command ) {
			foreach ( $invs as $inv ) {
				$meta['installs'][] = self::install_meta( $inv, null );
			}
		}
		foreach ( $plan['coverage']['skipped'] as $reason => $s ) {
			$notes[] = 'Discovery: ' . self::count_text( $s['count'], 'folder' ) . ' left out (' . $reason . '), such as ' . implode( ', ', $s['examples'] );
		}
		if ( $lookups->failed ) {
			$incomplete = true;
			$notes[]    = $opt['offline']
				? 'Offline: official checksums and vulnerability data not checked (' . self::count_text( $lookups->failed, 'lookup' ) . ').'
				: self::count_text( $lookups->failed, 'lookup' ) . ' on wordpress.org or wpvulnerability.net got no answer; run again later.';
		}

		// Exit code and summary.
		$counts = array();
		$worst  = 0;
		foreach ( $rows as $row ) {
			if ( isset( $row['severity'] ) ) {
				$counts[ $row['severity'] ] = ( isset( $counts[ $row['severity'] ] ) ? $counts[ $row['severity'] ] : 0 ) + 1;
				$worst                      = max( $worst, IsHacked_Cli_Scan::RANK[ $row['severity'] ] );
			}
		}
		if ( 'find' !== $command ) {
			$parts = array();
			foreach ( array_keys( IsHacked_Cli_Scan::RANK ) as $sev ) {
				if ( ! empty( $counts[ $sev ] ) ) {
					$parts[] = $counts[ $sev ] . ' ' . $sev;
				}
			}
			$summary[] = $parts ? 'Findings: ' . implode( ', ', $parts ) . '.' : 'No findings.';
		}
		foreach ( $notes as $note ) {
			$summary[] = 'Note: ' . $note;
		}
		$meta['summary'] = array(
			'counts'     => (object) $counts,
			'incomplete' => $incomplete,
			'notes'      => $notes,
			'seconds'    => round( microtime( true ) - $started, 1 ),
		);

		$out = STDOUT;
		if ( '' !== $opt['output_file'] ) {
			$out = @fopen( $opt['output_file'], 'wb' );
			if ( ! $out ) {
				throw new InvalidArgumentException( 'Cannot write ' . $opt['output_file'] );
			}
		}
		( new IsHacked_Cli_Output( $out, $opt['format'], $opt['columns'], $opt['header'] ) )->write( $rows, $meta, $summary );
		if ( STDOUT !== $out ) {
			fclose( $out );
		}
		if ( 'human' !== $opt['format'] && ! $opt['quiet'] && function_exists( 'stream_isatty' ) && stream_isatty( STDERR ) ) {
			fwrite( STDERR, implode( "\n", array_map( array( 'IsHacked_Cli_Output', 'clean' ), $summary ) ) . "\n" );
		}

		if ( 'find' !== $command && $worst >= IsHacked_Cli_Scan::RANK[ $opt['fail_on'] ] ) {
			return self::EXIT_FINDINGS;
		}
		return $incomplete && $opt['strict'] ? self::EXIT_INCOMPLETE : self::EXIT_CLEAN;
	}

	/**
	 * Local findings from an install's database (scan --db). Changes over time (a new administrator)
	 * need the dashboard, which keeps the previous report.
	 *
	 * @param string     $path  Install.
	 * @param array|null $facts From IsHacked_Cli_Database::read().
	 * @return array[]
	 */
	private static function database_rows( $path, $facts ) {
		if ( ! $facts ) {
			return array();
		}
		$rows = array();
		$row  = function ( $check, $severity, $detail ) use ( $path ) {
			return array(
				'install'  => $path,
				'path'     => 'database',
				'check'    => $check,
				'severity' => $severity,
				'detail'   => $detail,
				'sha256'   => '',
			);
		};
		if ( $facts['registration']['open'] && 'administrator' === $facts['registration']['defaultRole'] ) {
			$rows[] = $row( 'db:registration-admin', 'high', 'Anyone can register, and new users become administrators. Attackers turn this on to create their own accounts.' );
		}
		$logins = array_column( $facts['administrators'], 'login' );
		$rows[] = $row( 'db:administrators', 'info', count( $logins ) . ' administrators: ' . implode( ', ', array_slice( $logins, 0, 20 ) ) . ( count( $logins ) > 20 ? ', â€¦' : '' ) . '. Check that you know each one.' );
		if ( $facts['suspiciousPostIds'] ) {
			$rows[] = $row( 'db:post-embeds', 'info', count( $facts['suspiciousPostIds'] ) . ' posts contain scripts or iframes (IDs ' . implode( ', ', array_slice( $facts['suspiciousPostIds'], 0, 20 ) ) . '). Embeds can be legitimate; injected spam hides there too.' );
		}
		return $rows;
	}

	/**
	 * What to scan: installs (path => the part to scan, '' for all of it) and folders outside them.
	 *
	 * @param array $opt Options.
	 * @return array{installs: array<string, string>, loose: string[], coverage: array}
	 */
	public static function plan( array $opt ) {
		$installs = array();
		$loose    = array();
		$coverage = IsHacked_Cli_Fs::coverage();
		foreach ( $opt['paths'] as $path ) {
			$container = IsHacked_Cli_Installs::containing( $path );
			if ( '' !== $container && $container !== $path ) {
				// A folder inside an install: scan that part, judged as part of the install.
				if ( ! isset( $installs[ $container ] ) || '' !== $installs[ $container ] ) {
					$installs[ $container ] = substr( $path, strlen( $container ) + 1 );
				}
				continue;
			}
			foreach ( IsHacked_Cli_Installs::discover( $path, $opt['max_depth'], $opt['exclude'], $coverage ) as $found ) {
				$installs[ $found ] = '';
			}
			if ( $container !== $path ) {
				$loose[] = $path;
			}
		}
		ksort( $installs, SORT_STRING );
		// A folder inside another given folder is already covered by it.
		$loose = array_values(
			array_filter(
				$loose,
				function ( $p ) use ( $loose ) {
					foreach ( $loose as $other ) {
						if ( $other !== $p && 0 === strpos( $p, $other . '/' ) ) {
							return false;
						}
					}
					return true;
				}
			)
		);
		return array(
			'installs' => $installs,
			'loose'    => $loose,
			'coverage' => $coverage,
		);
	}

	/**
	 * The installs below a folder, as prune paths, so each is walked once, as itself.
	 *
	 * @param string   $root     Folder.
	 * @param string[] $installs Install paths.
	 * @return array<string, bool>
	 */
	private static function inside( $root, array $installs ) {
		$out = array();
		foreach ( $installs as $path ) {
			if ( 0 === strpos( $path, $root . '/' ) ) {
				$out[ $path ] = true;
			}
		}
		return $out;
	}

	/**
	 * Rows in install order, worst first, then by path.
	 *
	 * @param array[]  $rows  Rows.
	 * @param string[] $order Install paths in order.
	 * @return array[]
	 */
	private static function sorted( array $rows, array $order ) {
		$pos = array_flip( $order );
		usort(
			$rows,
			function ( $a, $b ) use ( $pos ) {
				$ia = isset( $pos[ $a['install'] ] ) ? $pos[ $a['install'] ] : PHP_INT_MAX;
				$ib = isset( $pos[ $b['install'] ] ) ? $pos[ $b['install'] ] : PHP_INT_MAX;
				if ( $ia !== $ib ) {
					return $ia <=> $ib;
				}
				$sa = IsHacked_Cli_Scan::RANK[ $a['severity'] ];
				$sb = IsHacked_Cli_Scan::RANK[ $b['severity'] ];
				if ( $sa !== $sb ) {
					return $sb <=> $sa;
				}
				$ka = isset( $a['path'] ) ? $a['path'] : $a['slug'];
				$kb = isset( $b['path'] ) ? $b['path'] : $b['slug'];
				return strcmp( (string) $ka, (string) $kb );
			}
		);
		return $rows;
	}

	/**
	 * An install for the json output.
	 *
	 * @param array      $inv      From IsHacked_Cli_Installs::inventory().
	 * @param array|null $coverage Scan coverage.
	 * @return array
	 */
	private static function install_meta( array $inv, $coverage ) {
		$plugins = array();
		foreach ( $inv['plugins'] as $slug => $p ) {
			$plugins[ (string) $slug ] = $p['version'];
		}
		$themes = array();
		foreach ( $inv['themes'] as $slug => $t ) {
			$themes[ (string) $slug ] = $t['version'];
		}
		$out = array(
			'path'       => $inv['path'],
			'wordpress'  => $inv['version'],
			'locale'     => $inv['locale'],
			'multisite'  => $inv['multisite'],
			'plugins'    => (object) $plugins,
			'themes'     => (object) $themes,
			'mu_plugins' => $inv['mu'],
			'dropins'    => $inv['dropins'],
		);
		if ( null !== $coverage ) {
			$out['coverage'] = $coverage;
		}
		return $out;
	}

	/**
	 * "1 file", "2 files".
	 *
	 * @param int    $n    Count.
	 * @param string $noun Singular.
	 * @return string
	 */
	private static function count_text( $n, $noun ) {
		return number_format( $n ) . ' ' . $noun . ( 1 === (int) $n ? '' : 's' );
	}

	/**
	 * Gives large scans room: at least 1 GB unless PHP's limit is already higher or off.
	 */
	private static function raise_memory_limit() {
		$limit = trim( (string) ini_get( 'memory_limit' ) );
		if ( '-1' === $limit ) {
			return;
		}
		$units = array(
			'k' => 1024,
			'm' => 1048576,
			'g' => 1073741824,
		);
		$last  = strtolower( substr( $limit, -1 ) );
		$bytes = isset( $units[ $last ] ) ? (int) $limit * $units[ $last ] : (int) $limit;
		if ( $bytes < 1073741824 ) {
			@ini_set( 'memory_limit', '1G' );
		}
	}

	/**
	 * Prints an error.
	 *
	 * @param string $message Message.
	 * @return int EXIT_ERROR.
	 */
	private static function fail( $message ) {
		fwrite( STDERR, 'ishacked: ' . $message . "\n" );
		return self::EXIT_ERROR;
	}

	/**
	 * The help text.
	 *
	 * @return string
	 */
	public static function help() {
		return <<<'TXT'
ishacked: scans the WordPress installs on this server for signs of a hack. Read-only: it never
changes a file and never loads WordPress. https://ishacked.com/cli

Usage:
  ishacked scan [folders...]        Files: malware patterns, files that differ from the official
                                    WordPress and wordpress.org plugin releases, exposed backups
  ishacked vuln-scan [folders...]   Known vulnerabilities in WordPress, plugins and themes
  ishacked find [folders...]        Lists the WordPress installs found
  ishacked version | help

Connected to an IsHacked account (https://ishacked.com/app/servers):
  ishacked connect CODE [folders...]  Connects this server with a code from the Servers page
  ishacked report [folders...]        Sends a signed report (run it from cron; connect prints the line)
  ishacked status | disconnect

Folders default to the current one. Installs are found up to --max-depth folders down; files
outside any install are scanned too (scan only).

Options:
  --output human|tsv|csv|json   Format (human on a terminal, tsv otherwise)
  --output-file FILE            Write there instead of to the screen
  --columns a,b,c               Columns to show, in order
  --no-header                   No header row (tsv, csv)
  --fail-on LEVEL               Exit 1 for findings at or above critical, high, medium (default),
                                low or info
  --strict                      Exit 3 when something couldn't be checked
  --offline                     No network: no official checksums or vulnerability data
  --cache-dir DIR               Lookup cache (default ~/.cache/ishacked; "none" for none)
  --exclude PATTERN             Leave out matching paths (shell glob; repeatable)
  --max-depth N                 How deep to look for installs (default 6)
  --workers N                   Processes for scan (default: CPUs, up to 8; needs pcntl)
  --all-files                   Scan every file, not only code and server configuration
  --max-scan-bytes N            Read files up to this size for patterns (default 1048576)
  --quiet                       No progress or summary on stderr
  --db                          scan: also read each install's database (read-only, from wp-config.php)
  --server                      scan: also check crontab, temporary folders, processes, parent folders
  --config FILE                 Connection file (default ~/.config/ishacked/companion.json)
  --name NAME, --yes            connect: the server's name in the dashboard; don't ask to confirm

Exit codes: 0 nothing at or above --fail-on, 1 findings, 2 error, 3 incomplete (with --strict).

Only WordPress versions and plugin and theme names are sent, to api.wordpress.org,
downloads.wordpress.org and www.wpvulnerability.net.

TXT;
	}
}
<?php
/**
 * Rules for what doesn't belong in a WordPress install, judged from a file's name, path and content.
 * Pure: no WordPress functions, so the server companion (ishacked.phar) runs the same rules as the
 * plugin without loading WordPress.
 *
 * @package IsHacked
 */

defined( 'ABSPATH' ) || exit;

/**
 * File rules shared by the plugin and the server companion.
 */
final class IsHacked_Rules {

	/** Bumped when flags() changes, so the next walk re-reads every file (IsHacked_Collector). */
	const VERSION = 4;

	/** .htaccess directives that make a folder run code (an uploads .htaccess denying access is fine). */
	const HTACCESS_CODE_RE = '/^\s*(?:AddHandler|AddType|SetHandler|ForceType|Action|php_value|php_flag|php_admin_value|php_admin_flag|RemoveHandler)\b/im';

	/** Files worth inventorying: code, and the configuration files that change how PHP runs. */
	const CODE_RE   = '/\.(?:php\d?|phtml|phar|pht|phps|inc|js|html?|css|ico)$/i';
	const PHP_RE    = '/\.(?:php\d?|phtml|phar|pht|phps|inc)$/i';
	const CONFIG_RE = '/^(?:\.htaccess|\.user\.ini|php\.ini)$/i';

	/** Dotfiles that developer tools ship inside plugins' vendor folders. */
	const TOOL_DOTFILES_RE = '/^\.(?:php[-_]cs(?:[-_]fixer)?(?:\.dist)?|phpstorm\.meta)\.php$/i';

	/**
	 * Code patterns, written as expressions rather than literal strings so other security scanners
	 * don't mistake this file for what it looks for. Review signals, not verdicts.
	 */
	const PATTERNS = array(
		'eval-decode' => '/\b(?:eval|assert)\s*\(\s*(?:base64_decode|gzinflate|gzuncompress|gzdecode|str_rot13|strrev|hex2bin)\s*\(/i',
		'preg-e'      => '/\bpreg_replace\s*\(\s*[\'"]\/[^\'"]{0,300}\/[a-zA-Z]*e[a-zA-Z]*[\'"]\s*,/',
		'input-call'  => '/\$_(?:POST|GET|REQUEST|COOKIE|SERVER)\s*\[[^\]]{1,64}\]\s*\(/',
		'packed'      => '/(?:base64_decode|gzinflate|str_rot13)\s*\(\s*[\'"][A-Za-z0-9+\/=]{1500,}/',
		'hex-escapes' => '/(?:\\\\x[0-9a-fA-F]{2}){40,}/',
		'webshell'    => '/F[i]lesMan|\bW[S]O\s*[0-9.]+|c9[9]sh|r5[7]shell|b3[7]4k|Ind[o]Xploit|\bal[f]a\s*(?:team|shell)|w[s]o_version|\bmini\s*s[h]ell/i',
		'remote-code' => '/\b(?:include|require)(?:_once)?\s*\(?\s*[\'"](?:https?|ftp|php|data):/i',
		'image-code'  => '/(?:^|[;{}]|<\?php)\s*@?(?:include|require)(?:_once)?\s*\(?\s*[^;\n]{0,200}\.(?:ico|png|jpe?g|gif|bmp|txt)[\'"]\s*\)?\s*;/im',
		'chr-chain'   => '/(?:\bchr\s*\(\s*\d+\s*\)\s*\.\s*){15,}/i',
		'var-globals' => '/\$\{\s*[\'"]\\\\x[0-9a-f]{2}/i',
		'long-line'   => '/^[^\n]{50000,}$/m',
	);

	/**
	 * What's wrong with a file, from its path and (for files up to 1 MB) its content.
	 *
	 * @param string      $path Reported path.
	 * @param string|null $text Content, or null when not read.
	 * @return string[]
	 */
	public static function flags( $path, $text ) {
		$flags   = array();
		$name    = basename( $path );
		$php     = (bool) preg_match( self::PHP_RE, $name );
		$uploads = 0 === strpos( $path, 'wp-content/uploads/' );
		if ( $uploads && $php ) {
			$flags[] = 'executable-upload';
		}
		if ( $uploads && preg_match( self::CONFIG_RE, $name ) && ( '.htaccess' !== strtolower( $name ) || null === $text || preg_match( self::HTACCESS_CODE_RE, $text ) ) ) {
			$flags[] = 'upload-config';
		}
		if ( preg_match( '/\.(?:php\d?|phtml|phar)\.[a-z0-9]{2,5}$/i', $name ) || preg_match( '/\.(?:jpe?g|png|gif|ico|svg|webp|txt|zip)\.php\d?$/i', $name ) ) {
			$flags[] = 'double-extension';
		}
		// Hidden PHP files, unless they hold no PHP: macOS zips leave binary "._name.php" metadata files.
		if ( $php && '.' === $name[0] && ! preg_match( self::TOOL_DOTFILES_RE, $name ) && ( null === $text || false !== strpos( $text, '<?' ) ) ) {
			$flags[] = 'hidden-php';
		}
		if ( null !== $text && preg_match( '/^(?:\.user\.ini|php\.ini|\.htaccess)$/i', $name ) && preg_match( '/\bauto_(?:prepend|append)_file\b/i', $text ) ) {
			$flags[] = 'auto-prepend';
		}
		if ( null !== $text && $php ) {
			foreach ( self::PATTERNS as $id => $re ) {
				if ( preg_match( $re, $text ) ) {
					$flags[] = 'code:' . $id;
				}
			}
			// A maze of goto jumps is a known obfuscator's signature.
			if ( substr_count( $text, 'goto ' ) >= 40 ) {
				$flags[] = 'code:goto-maze';
			}
			// Saving uploaded files from a file outside plugins, themes and core.
			if ( preg_match( '/\bmove_uploaded_file\s*\(/i', $text ) && ! preg_match( '#^(?:wp-admin|wp-includes|wp-content/(?:plugins|themes))/#', $path ) ) {
				$flags[] = 'code:uploader';
			}
		}
		// Images and icons don't contain PHP; malware hides there and is included from elsewhere.
		if ( null !== $text && preg_match( '/\.ico$/i', $name ) && false !== strpos( $text, '<?php' ) ) {
			$flags[] = 'php-in-image';
		}
		// Translation files are .mo, .po, .json or .l10n.php; any other PHP in languages is out of place.
		if ( $php && 0 === strpos( $path, 'wp-content/languages/' ) && ! preg_match( '/\.l10n\.php$/', $name ) && 'index.php' !== $name ) {
			$flags[] = 'unexpected-php';
		}
		if ( null !== $text && '.htaccess' === strtolower( $name ) ) {
			if ( preg_match( '/^\s*(?:AddHandler|AddType|SetHandler|ForceType)\b[^\n]*(?:php|x-httpd)[^\n]*\.(?:jpe?g|png|gif|ico|txt|html?|svg|bmp)\b/im', $text ) ) {
				$flags[] = 'htaccess-handler';
			}
			if ( preg_match( '/RewriteCond\s+%\{HTTP_(?:USER_AGENT|REFERER)\}[^\n]*(?:google|bing|yahoo|baidu|yandex|duckduckgo|android|iphone|mobile|facebook)/i', $text ) && preg_match( '#RewriteRule\s+\S+\s+https?://#i', $text ) ) {
				$flags[] = 'htaccess-redirect';
			}
		}
		return $flags;
	}

	/**
	 * What kind of exposure a name is, or '' for none.
	 *
	 * @param string $name   File or folder name.
	 * @param bool   $is_dir Whether it's a folder.
	 * @return string
	 */
	public static function exposure_kind( $name, $is_dir ) {
		if ( $is_dir ) {
			return in_array( $name, array( '.git', '.svn', '.hg' ), true ) ? 'vcs' : '';
		}
		if ( preg_match( '/^wp-config\.php.+|^wp-config[^.]*\.(?:bak|old|orig|save|txt|php\d?)$|^\.?wp-config\.php\.swp$/i', $name ) && 'wp-config.php' !== $name && 'wp-config-sample.php' !== $name ) {
			return 'config-copy';
		}
		if ( preg_match( '/\.(?:sql|sql\.gz|sql\.zip|dump|mysql)$/i', $name ) ) {
			return 'database';
		}
		if ( preg_match( '/\.(?:zip|tar|tar\.gz|tgz|gz|rar|7z|bak|wpress|daf)$/i', $name ) ) {
			return 'archive';
		}
		if ( preg_match( '/^\.env(?:\.\w+)?$|^\.htpasswd$|^id_rsa|\.pem$|\.key$/i', $name ) ) {
			return 'secret';
		}
		if ( preg_match( '/^(?:error_log|php_errorlog|debug\.log|php_error\.log)$/i', $name ) ) {
			return 'log';
		}
		if ( preg_match( '/^(?:adminer[-\w.]*|phpmyadmin|pma|sqlbuddy|dumper|bigdump|searchreplacedb2?|srdb|tinyfilemanager|filemanager|file-manager|w[s]o|s[h]ell|c[9]9|r[5]7|b37[4]k|al[f]a|ind[o]xploit)\.php\d?$/i', $name ) ) {
			return 'tool';
		}
		if ( preg_match( '/^(?:phpinfo|info|pi|test|i|php)\.php$/i', $name ) ) {
			return 'maybe-phpinfo';
		}
		return '';
	}
}
<?php
/**
 * Compares files with wordpress.org's official checksums. Pure: no WordPress functions, so the server
 * companion (ishacked.phar) compares the same way as the plugin.
 *
 * @package IsHacked
 */

defined( 'ABSPATH' ) || exit;

/**
 * Official checksum comparison shared by the plugin and the server companion.
 */
final class IsHacked_Compare {

	/**
	 * Core checksums from api.wordpress.org/core/checksums/1.0/: path => md5, or an empty array when
	 * wordpress.org has none for that version and locale.
	 *
	 * @param mixed $data Decoded JSON.
	 * @return array<string, string>
	 */
	public static function parse_core( $data ) {
		$out  = array();
		$sums = is_array( $data ) && isset( $data['checksums'] ) && is_array( $data['checksums'] ) ? $data['checksums'] : array();
		foreach ( $sums as $path => $md5 ) {
			if ( is_string( $path ) && is_string( $md5 ) && preg_match( '/^[a-f0-9]{32}$/', $md5 ) ) {
				$out[ $path ] = $md5;
			}
		}
		return $out;
	}

	/**
	 * Plugin checksums from downloads.wordpress.org/plugin-checksums/: relative path => list of md5s
	 * (a file shipped in several builds of one version lists every md5 it had), or an empty array.
	 *
	 * @param mixed $data Decoded JSON.
	 * @return array<string, string[]>
	 */
	public static function parse_plugin( $data ) {
		$out   = array();
		$files = is_array( $data ) && isset( $data['files'] ) && is_array( $data['files'] ) ? $data['files'] : array();
		foreach ( $files as $path => $sums ) {
			if ( is_string( $path ) && is_array( $sums ) && isset( $sums['md5'] ) ) {
				$out[ $path ] = is_array( $sums['md5'] ) ? array_values( array_map( 'strval', $sums['md5'] ) ) : array( (string) $sums['md5'] );
			}
		}
		return $out;
	}

	/**
	 * Core files compared with the official list.
	 *
	 * @param array $files    path => md5 for core paths (wp-admin, wp-includes, root files).
	 * @param array $expected From parse_core().
	 * @param bool  $complete Whether the file walk finished (only then can files be called missing).
	 * @return array{modified: string[], unexpected: string[], missing: string[]}
	 */
	public static function core( array $files, array $expected, $complete ) {
		$out = array(
			'modified'   => array(),
			'unexpected' => array(),
			'missing'    => array(),
		);
		foreach ( $files as $path => $md5 ) {
			if ( isset( $expected[ $path ] ) ) {
				if ( '' !== $md5 && $expected[ $path ] !== $md5 ) {
					$out['modified'][] = $path;
				}
			} elseif ( preg_match( '#^wp-(?:admin|includes)/#', $path ) ) {
				$out['unexpected'][] = $path;
			}
		}
		if ( $complete ) {
			foreach ( array_keys( $expected ) as $path ) {
				if ( preg_match( '/\.(?:php|js|css)$/', $path ) && ! isset( $files[ $path ] ) && 0 !== strpos( $path, 'wp-content/' ) ) {
					$out['missing'][] = $path;
				}
			}
		}
		return $out;
	}

	/**
	 * One plugin's files compared with wordpress.org's.
	 *
	 * @param array $files    Relative path => md5 inside the plugin folder.
	 * @param array $expected From parse_plugin().
	 * @return array{modified: string[], unexpected: string[]}
	 */
	public static function plugin( array $files, array $expected ) {
		$out = array(
			'modified'   => array(),
			'unexpected' => array(),
		);
		foreach ( $files as $path => $md5 ) {
			if ( ! isset( $expected[ $path ] ) ) {
				$out['unexpected'][] = $path;
			} elseif ( '' !== $md5 && ! in_array( $md5, $expected[ $path ], true ) ) {
				$out['modified'][] = $path;
			}
		}
		return $out;
	}
}
<?php
/**
 * Reads vulnerability answers from wpvulnerability.net and plugin answers from the wordpress.org
 * plugin API, and decides which vulnerabilities affect a version. Pure: no WordPress functions are
 * required (WordPress's own are used when loaded), so the server companion (ishacked.phar) judges
 * vulnerabilities the same way as the plugin.
 *
 * @package IsHacked
 */

defined( 'ABSPATH' ) || exit;

/**
 * Vulnerability data shared by the plugin and the server companion.
 */
final class IsHacked_Advisories {

	const API = 'https://www.wpvulnerability.net/';

	/**
	 * Advisory hosts linked to, by label.
	 */
	const SOURCES = array(
		'www.cve.org'       => 'CVE',
		'cve.org'           => 'CVE',
		'nvd.nist.gov'      => 'NVD',
		'wpscan.com'        => 'WPScan',
		'www.wordfence.com' => 'Wordfence',
		'patchstack.com'    => 'Patchstack',
	);

	/**
	 * The wpvulnerability.net address for a core version, plugin or theme, or '' for a slug it can't
	 * have.
	 *
	 * @param string $type core, plugin or theme.
	 * @param string $slug Slug, or the version for core.
	 * @return string
	 */
	public static function url( $type, $slug ) {
		if ( 'core' === $type ? ! preg_match( '/^\d+\.\d+(?:\.\d+)?$/', $slug ) : ! preg_match( '/^[a-z0-9._-]{1,100}$/', $slug ) ) {
			return '';
		}
		return self::API . $type . '/' . rawurlencode( $slug ) . '/';
	}

	/**
	 * The wordpress.org plugin API address for a plugin's status, without the long fields.
	 *
	 * @param string $slug Plugin folder.
	 * @return string
	 */
	public static function info_url( $slug ) {
		$fields = array( 'sections', 'description', 'screenshots', 'banners', 'contributors', 'ratings', 'versions', 'reviews', 'downloadlink', 'donate_link', 'tags', 'compatibility' );
		$query  = 'action=plugin_information&request%5Bslug%5D=' . rawurlencode( $slug );
		foreach ( $fields as $field ) {
			$query .= '&request%5Bfields%5D%5B' . $field . '%5D=0';
		}
		return 'https://api.wordpress.org/plugins/info/1.2/?' . $query;
	}

	/**
	 * A wpvulnerability.net answer as closed status and a list of vulnerabilities.
	 *
	 * @param string $type core, plugin or theme.
	 * @param mixed  $body Decoded JSON.
	 * @return array{closed: bool, reason: string, vulns: array[]}
	 */
	public static function parse( $type, $body ) {
		$data = is_array( $body ) && isset( $body['data'] ) && is_array( $body['data'] ) ? $body['data'] : array();
		$out  = array(
			'closed' => ! empty( $data['closed'] ),
			'reason' => isset( $data['closed_reason'] ) && is_string( $data['closed_reason'] ) ? substr( $data['closed_reason'], 0, 60 ) : '',
			'vulns'  => array(),
		);
		foreach ( isset( $data['vulnerability'] ) && is_array( $data['vulnerability'] ) ? array_slice( $data['vulnerability'], 0, 200 ) : array() as $v ) {
			if ( ! is_array( $v ) ) {
				continue;
			}
			$op             = isset( $v['operator'] ) && is_array( $v['operator'] ) ? $v['operator'] : array();
			$cvss           = isset( $v['impact']['cvss3'] ) && is_array( $v['impact']['cvss3'] ) ? $v['impact']['cvss3'] : ( isset( $v['impact']['cvss'] ) && is_array( $v['impact']['cvss'] ) ? $v['impact']['cvss'] : array() );
			$sources        = isset( $v['source'] ) && is_array( $v['source'] ) ? array_slice( $v['source'], 0, 10 ) : array();
			$source         = isset( $sources[0] ) && is_array( $sources[0] ) ? $sources[0] : array();
			$out['vulns'][] = array(
				// Core entries are named after the version; their source names the actual problem.
				'name'    => substr( self::plain( (string) ( 'core' === $type && isset( $source['name'] ) ? $source['name'] : ( isset( $v['name'] ) ? $v['name'] : '' ) ) ), 0, 200 ),
				'min'     => isset( $op['min_version'] ) ? (string) $op['min_version'] : '',
				'min_op'  => isset( $op['min_operator'] ) ? (string) $op['min_operator'] : '',
				'max'     => isset( $op['max_version'] ) ? (string) $op['max_version'] : '',
				'max_op'  => isset( $op['max_operator'] ) ? (string) $op['max_operator'] : '',
				'unfixed' => ! empty( $op['unfixed'] ) && '0' !== (string) $op['unfixed'],
				'score'   => isset( $cvss['score'] ) ? (float) $cvss['score'] : 0.0,
				'id'      => isset( $source['id'] ) ? substr( (string) $source['id'], 0, 60 ) : '',
				'links'   => self::links( $sources ),
			);
		}
		return $out;
	}

	/**
	 * A wordpress.org plugin API answer as found, closed, last update and latest version, or null when
	 * it isn't an answer (a 200 or a 404 with JSON).
	 *
	 * @param int   $code HTTP status.
	 * @param mixed $d    Decoded JSON.
	 * @return array|null
	 */
	public static function parse_info( $code, $d ) {
		if ( ! is_array( $d ) || ( 200 !== $code && 404 !== $code ) ) {
			return null;
		}
		return array(
			'found'   => empty( $d['error'] ) || 'closed' === $d['error'],
			'closed'  => ! empty( $d['closed'] ),
			'reason'  => isset( $d['reason_text'] ) ? substr( (string) $d['reason_text'], 0, 80 ) : '',
			'updated' => isset( $d['last_updated'] ) ? (int) strtotime( (string) $d['last_updated'] ) : 0,
			'latest'  => isset( $d['version'] ) ? substr( (string) $d['version'], 0, 30 ) : '',
		);
	}

	/**
	 * Where to read about a vulnerability: the advisories from known sources, at most one per source, in
	 * the order of SOURCES.
	 *
	 * @param array $sources The entry's sources.
	 * @return array[] [label, https URL] pairs.
	 */
	public static function links( array $sources ) {
		$out = array();
		foreach ( $sources as $source ) {
			$url  = is_array( $source ) && isset( $source['link'] ) && is_string( $source['link'] ) ? $source['link'] : '';
			$host = strtolower( (string) self::host( $url ) );
			if ( 0 !== strpos( $url, 'https://' ) || strlen( $url ) > 300 || preg_match( '/[\s"\'<>\\\\`]/', $url ) || ! isset( self::SOURCES[ $host ] ) ) {
				continue;
			}
			$label = self::SOURCES[ $host ];
			if ( 'CVE' === $label ) {
				$id    = isset( $source['id'] ) ? (string) $source['id'] : '';
				$label = preg_match( '/^CVE-\d{4}-\d{4,7}$/', $id ) ? $id : $label;
			}
			$out[ self::SOURCES[ $host ] ] = array( $label, function_exists( 'esc_url_raw' ) ? esc_url_raw( $url ) : $url );
		}
		return array_values( array_filter( array_merge( array_fill_keys( array_unique( self::SOURCES ), null ), $out ) ) );
	}

	/**
	 * Links for a vulnerability; entries cached before links were kept get one built from a CVE id.
	 *
	 * @param array $v Vulnerability.
	 * @return array[] [label, URL] pairs.
	 */
	public static function links_of( array $v ) {
		if ( isset( $v['links'] ) && is_array( $v['links'] ) ) {
			return $v['links'];
		}
		return isset( $v['id'] ) && preg_match( '/^CVE-\d{4}-\d{4,7}$/', $v['id'] ) ? array( array( $v['id'], 'https://www.cve.org/CVERecord?id=' . $v['id'] ) ) : array();
	}

	/**
	 * The vulnerabilities that affect a version, worst first.
	 *
	 * @param array  $data    From parse() (or the core entry, already for one version).
	 * @param string $version Installed version, '' for core (its list is for the version asked).
	 * @return array[]
	 */
	public static function affecting( array $data, $version ) {
		$out = array();
		foreach ( isset( $data['vulns'] ) ? $data['vulns'] : array() as $v ) {
			if ( '' === $version || self::in_range( $version, $v ) ) {
				$out[] = $v;
			}
		}
		usort(
			$out,
			function ( $a, $b ) {
				return $b['score'] <=> $a['score'];
			}
		);
		return $out;
	}

	/**
	 * Whether a version falls in a vulnerability's range.
	 *
	 * @param string $version Version.
	 * @param array  $v       Vulnerability.
	 * @return bool
	 */
	public static function in_range( $version, array $v ) {
		$ops = array(
			'lt' => '<',
			'le' => '<=',
			'gt' => '>',
			'ge' => '>=',
			'eq' => '==',
		);
		if ( '' !== $v['max'] && isset( $ops[ $v['max_op'] ] ) && ! version_compare( $version, $v['max'], $ops[ $v['max_op'] ] ) ) {
			return false;
		}
		if ( '' !== $v['min'] && isset( $ops[ $v['min_op'] ] ) && ! version_compare( $version, $v['min'], $ops[ $v['min_op'] ] ) ) {
			return false;
		}
		return '' !== $v['max'] || '' !== $v['min'] || $v['unfixed'];
	}

	/**
	 * The version that fixes a vulnerability, when its range ends below one, else ''.
	 *
	 * @param array $v Vulnerability.
	 * @return string
	 */
	public static function fixed_in( array $v ) {
		return '' !== $v['max'] && 'lt' === $v['max_op'] && empty( $v['unfixed'] ) ? $v['max'] : '';
	}

	/**
	 * A severity level from a CVSS score: critical, high, medium, low or unrated.
	 *
	 * @param float $score Score.
	 * @return string
	 */
	public static function level( $score ) {
		if ( $score >= 9 ) {
			return 'critical';
		}
		if ( $score >= 7 ) {
			return 'high';
		}
		if ( $score >= 4 ) {
			return 'medium';
		}
		return $score > 0 ? 'low' : 'unrated';
	}

	/**
	 * Text without markup or entities.
	 *
	 * @param string $text Text.
	 * @return string
	 */
	private static function plain( $text ) {
		// Tags go first, then entities: decoding first would turn "&lt;= 1.2" into a tag and lose the rest.
		if ( function_exists( 'wp_strip_all_tags' ) ) {
			$text = wp_strip_all_tags( $text );
		} else {
			$text = trim( strip_tags( (string) preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $text ) ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- Outside WordPress (ishacked.phar); the same as wp_strip_all_tags().
		}
		return html_entity_decode( $text, ENT_QUOTES );
	}

	/**
	 * A URL's host.
	 *
	 * @param string $url URL.
	 * @return string|null
	 */
	private static function host( $url ) {
		return function_exists( 'wp_parse_url' ) ? wp_parse_url( $url, PHP_URL_HOST ) : parse_url( $url, PHP_URL_HOST ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Outside WordPress (ishacked.phar).
	}
}
<?php
/**
 * Reads plugin and theme headers ("Plugin Name:", "Version:") from a file's text the way WordPress's
 * get_file_data() does, without WordPress, for the server companion (ishacked.phar).
 *
 * @package IsHacked
 */

defined( 'ABSPATH' ) || exit;

/**
 * Header comments.
 */
final class IsHacked_Headers {

	/** WordPress reads headers from the first 8 KB. */
	const BYTES = 8192;

	/**
	 * Header values by key, '' when absent.
	 *
	 * @param string                $text   File content (only the first 8 KB is read).
	 * @param array<string, string> $fields key => header name, such as array( 'version' => 'Version' ).
	 * @return array<string, string>
	 */
	public static function read( $text, array $fields ) {
		$text = str_replace( "\r", "\n", substr( $text, 0, self::BYTES ) );
		$out  = array();
		foreach ( $fields as $key => $name ) {
			$out[ $key ] = preg_match( '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote( $name, '/' ) . ':(.*)$/mi', $text, $m )
				? trim( (string) preg_replace( '/\s*(?:\*\/|\?>).*/', '', $m[1] ) )
				: '';
		}
		return $out;
	}

	/**
	 * A plugin's name and version, or null when the file has no "Plugin Name:" header.
	 *
	 * @param string $text Main plugin file's content.
	 * @return array{name: string, version: string}|null
	 */
	public static function plugin( $text ) {
		$h = self::read(
			$text,
			array(
				'name'    => 'Plugin Name',
				'version' => 'Version',
			)
		);
		return '' === $h['name'] ? null : $h;
	}

	/**
	 * A theme's name, version and parent, or null when style.css has no "Theme Name:" header.
	 *
	 * @param string $text style.css content.
	 * @return array{name: string, version: string, template: string}|null
	 */
	public static function theme( $text ) {
		$h = self::read(
			$text,
			array(
				'name'     => 'Theme Name',
				'version'  => 'Version',
				'template' => 'Template',
			)
		);
		return '' === $h['name'] ? null : $h;
	}
}
e©—íÚôbûã>é�WÍ°;x(Ùù¬(áX–‘L–   GBMB