<?php
/**
 * Vuln_WPScan_Service Class.
 *
 * @package WP-CLI Vulnerability Scanner
 */

if ( ! defined( 'WP_CLI' ) ) {
	return;
}

/**
 * WP-CLI Vulnerability Scanner
 */
class Vulnerability_CLI extends WP_CLI_Command {

	/**
	 */
	private $porcelain;

	/**
	 */
	private $test;

	/**
	 */
	private $update_list;

	/**
	 */
	private $assoc_args_wordpress;

	/**
	 */
	private $assoc_args_plugin;

	/**
	 */
	private $assoc_args_theme;

	/**
	 * Flag for nagios
	 */
	private $nagios_op;

	/**
	 * Flag for mail
	 */
	private $mail;

	/**
	 * Flag for reference
	 */
	private $reference;

	/**
	 * Vulnerability Scanner API Service
	 *
	 * @var object
	 */
	private $service;

	/**
	 * Vulnerability API Provider copyrights.
	 *
	 * @var array
	 */
	private $copyrights = array();

	/**
	 * Check plugins and themes for reported vulnerabilities.
	 *
	 * ## OPTIONS
	 *
	 * [--test]
	 * : Load test plugin/theme data
	 *
	 * [--nagios]
	 * : Output for nagios
	 *
	 * [--mail]
	 * : Mail nagios output if any vulnerability found
	 *
	 * [--reference]
	 * : Add vulnerability reference link to the output
	 *
	 * [--format=<format>]
	 * : Accepted values: table, csv, json, count, ids, yaml. Default: table
	 *
	 * ## EXAMPLES
	 *
	 *     wp vuln status
	 *     wp vuln status --not-themes
	 *
	 * @subcommand status
	 */
	public function status( $args, $assoc_args ) {

		$this->init( $assoc_args );
		$format          = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
		$this->porcelain = false;
		$this->test      = isset( $assoc_args['test'] );

		$this->nagios_op = isset( $assoc_args['nagios'] );
		$this->reference = isset( $assoc_args['reference'] );
		$this->mail      = isset( $assoc_args['mail'] ) ? $assoc_args['mail'] : '';

		$this->update_list = array();

		// need a copy because it's passed by ref and destroyed in the formatter.
		$this->assoc_args_plugin    = $assoc_args;
		$this->assoc_args_theme     = $assoc_args;
		$this->assoc_args_wordpress = $assoc_args;

		global $wp_version;

		if ( $this->nagios_op ) {
			$this->do_nagios_op( array( 'wordpress', 'plugin', 'theme' ) );
		}

		if ( 'json' === $format ) {
			echo '{"core":';
			$this->do_wordpress();
			echo ',"plugins":';
			$this->do_plugins();
			echo ',"themes":';
			$this->do_themes();
			echo '}';
			exit( 0 );
		}

		if ( ! $this->porcelain && 'table' === $format ) {
			WP_CLI::log( $this->get_api_provider_credit() );
		}
		WP_CLI::log( WP_CLI::colorize( '%GWordPress ' . $wp_version . ' %n' ) );
		$this->do_wordpress();
		WP_CLI::log( WP_CLI::colorize( '%GPlugins%n' ) );
		$this->do_plugins();
		WP_CLI::log( WP_CLI::colorize( '%GThemes%n' ) );
		$this->do_themes();
		if ( ! $this->porcelain && 'table' === $format && ! empty( $this->get_copyright_notice() ) ) {
			WP_CLI::log( $this->get_copyright_notice() );
		}
	}

	/**
	 * Check WordPress core for reported vulnerabilities.
	 *
	 * ## OPTIONS
	 *
	 * [--nagios]
	 * : Output for nagios
	 *
	 * [--mail]
	 * : Mail nagios output if any vulnerability found
	 *
	 * [--reference]
	 * : Add vulnerability reference link to the output
	 *
	 * [--format=<format>]
	 * : Accepted values: table, csv, json, count, ids, yaml. Default: table
	 *
	 * ## EXAMPLES
	 *
	 *     wp vuln core-status
	 *
	 * @subcommand core-status
	 */
	public function core_status( $args, $assoc_args ) {

		$this->init( $assoc_args );
		$format          = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
		$this->porcelain = isset( $assoc_args['porcelain'] );
		$this->test      = isset( $assoc_args['test'] );

		$this->nagios_op = isset( $assoc_args['nagios'] );
		$this->reference = isset( $assoc_args['reference'] );
		$this->mail      = isset( $assoc_args['mail'] ) ? $assoc_args['mail'] : '';

		$this->update_list = array();

		// need a copy because it's passed by ref and destroyed in the formatter.
		$this->assoc_args_wordpress = $assoc_args;

		if ( $this->nagios_op ) {
			$this->do_nagios_op( array( 'wordpress' ) );
		}

		if ( ! $this->porcelain && 'table' === $format ) {
			WP_CLI::log( $this->get_api_provider_credit() );
		}
		$this->do_wordpress();

		// Display the copyright notice.
		if ( ! $this->porcelain && 'table' === $format && ! empty( $this->get_copyright_notice() ) ) {
			WP_CLI::log( $this->get_copyright_notice() );
		}
	}

	/**
	 * Check plugins for reported vulnerabilities.
	 *
	 * ## OPTIONS
	 *
	 * [--porcelain]
	 * : Print only slugs of plugins with updates
	 *
	 * [--test]
	 * : Load test plugin/theme data
	 *
	 * [--nagios]
	 * : Output for nagios
	 *
	 * [--mail]
	 * : Mail nagios output if any vulnerability found
	 *
	 * [--reference]
	 * : Add vulnerability reference link to the output
	 *
	 * [--format=<format>]
	 * : Accepted values: table, csv, json, count, ids, yaml. Default: table
	 *
	 * ## EXAMPLES
	 *
	 *     wp vuln plugin-status
	 *
	 * @subcommand plugin-status
	 */
	public function plugin_status( $args, $assoc_args ) {

		$this->init( $assoc_args );
		$format          = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
		$this->porcelain = isset( $assoc_args['porcelain'] );
		$this->test      = isset( $assoc_args['test'] );

		$this->nagios_op = isset( $assoc_args['nagios'] );
		$this->reference = isset( $assoc_args['reference'] );
		$this->mail      = isset( $assoc_args['mail'] ) ? $assoc_args['mail'] : '';

		$this->update_list = array();
		// need a copy because it's passed by ref and destroyed in the formatter.
		$this->assoc_args_plugin = $assoc_args;

		if ( $this->nagios_op ) {
			$this->do_nagios_op( array( 'plugin' ) );
		}

		if ( ! $this->porcelain && 'table' === $format ) {
			WP_CLI::log( $this->get_api_provider_credit() );
		}
		$this->do_plugins();

		// Display the copyright notice.
		if ( ! $this->porcelain && 'table' === $format && ! empty( $this->get_copyright_notice() ) ) {
			WP_CLI::log( $this->get_copyright_notice() );
		}
	}

	/**
	 * Check themes for reported vulnerabilities.
	 *
	 * ## OPTIONS
	 *
	 * [--porcelain]
	 * : Print only slugs of themes with updates
	 *
	 * [--test]
	 * : Load test theme/theme data
	 *
	 * [--nagios]
	 * : Output for nagios
	 *
	 * [--reference]
	 * : Add vulnerability reference link to the output
	 *
	 * [--format=<format>]
	 * : Accepted values: table, csv, json, count, ids, yaml. Default: table
	 *
	 * ## EXAMPLES
	 *
	 *     wp vuln theme-status
	 *
	 * @subcommand theme-status
	 */
	public function theme_status( $args, $assoc_args ) {

		$this->init( $assoc_args );
		$format          = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
		$this->porcelain = isset( $assoc_args['porcelain'] );
		$this->test      = isset( $assoc_args['test'] );
		$this->reference = isset( $assoc_args['reference'] );

		$this->nagios_op = isset( $assoc_args['nagios'] );

		$this->update_list = array();

		// need a copy because it's passed by ref and destroyed in the formatter.
		$this->assoc_args_theme = $assoc_args;

		if ( $this->nagios_op ) {
			$this->do_nagios_op( array( 'theme' ) );
		}

		if ( ! $this->porcelain && 'table' === $format ) {
			WP_CLI::log( $this->get_api_provider_credit() );
		}
		$this->do_themes();

		// Display the copyright notice.
		if ( ! $this->porcelain && 'table' === $format && ! empty( $this->get_copyright_notice() ) ) {
			WP_CLI::log( $this->get_copyright_notice() );
		}
	}

	/**
	 * Check any given theme.
	 *
	 * ## OPTIONS
	 *
	 * [<slug>...]
	 * : theme slug to check
	 *
	 * [--version]
	 * : Version if other than latest. Only applies if one slug provided
	 *
	 * [--reference]
	 * : Add vulnerability reference link to the output
	 *
	 * [--format=<format>]
	 * : Accepted values: table, csv, json, count, ids, yaml. Default: table
	 *
	 * ## EXAMPLES
	 *
	 *     wp vuln theme-check
	 *
	 * @subcommand theme-check
	 */
	public function theme_check( $args, $assoc_args ) {
		$this->reference = isset( $assoc_args['reference'] );
		$this->init( $assoc_args );
		if ( count( $args ) > 1 ) {
			$version = 0;
		} else {
			$version = isset( $assoc_args['version'] ) ? $assoc_args['version'] : 0;

		}

		$display = array();
		$format  = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
		foreach ( $args as $slug ) {
			$status  = $this->service->check_status( $slug, $version, 'theme' );
			$display = array_merge( $display, $status );
		}

		// Collect copyrights data.
		$this->collect_copyrights_data( $display );

		$fields = array(
			'name'              => true,
			'installed version' => false,
			'status'            => false,
			'fixed in'          => false,
			'severity'          => false,
		);
		if ( $this->reference ) {
			$fields['reference'] = false;
		}

		$formatter = new \WP_CLI\Formatter(
			$assoc_args,
			array_keys( $fields ),
			'themes'
		);

		if ( 'table' === $format ) {
			WP_CLI::log( $this->get_api_provider_credit() );
		}

		// Add second array parameter to indicate the position of the column having a maybe colorized item.
		$formatter->display_items( $display, array_values( $fields ) );

		// Display the copyright notice.
		if ( ! $this->porcelain && 'table' === $format && ! empty( $this->get_copyright_notice() ) ) {
			WP_CLI::log( $this->get_copyright_notice() );
		}
	}

	/**
	 * Check any given plugin.
	 *
	 * ## OPTIONS
	 *
	 * [<slug>...]
	 * : plugin slug to check
	 *
	 * [--version]
	 * : Version if other than latest. Only applies if one slug provided
	 *
	 * [--reference]
	 * : Add vulnerability reference link to the output
	 *
	 * [--format=<format>]
	 * : Accepted values: table, csv, json, count, ids, yaml. Default: table
	 *
	 * ## EXAMPLES
	 *
	 *     wp vuln plugin-check
	 *
	 * @subcommand plugin-check
	 */
	public function plugin_check( $args, $assoc_args ) {
		$this->reference = isset( $assoc_args['reference'] );
		$this->init( $assoc_args );
		if ( count( $args ) > 1 ) {
			$version = 0;
		} else {
			$version = isset( $assoc_args['version'] ) ? $assoc_args['version'] : 0;

		}

		$display = array();
		$format  = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table';
		foreach ( $args as $slug ) {
			$status  = $this->service->check_status( $slug, $version, 'plugin' );
			$display = array_merge( $display, $status );
		}

		// Collect copyrights data.
		$this->collect_copyrights_data( $display );

		$fields = array(
			'name'              => true,
			'installed version' => false,
			'status'            => false,
			'fixed in'          => false,
			'severity'          => false,
		);
		if ( $this->reference ) {
			$fields['reference'] = false;
		}

		$formatter = new \WP_CLI\Formatter(
			$assoc_args,
			array_keys( $fields ),
			'plugins'
		);

		if ( 'table' === $format ) {
			WP_CLI::log( $this->get_api_provider_credit() );
		}

		// Add second array parameter to indicate the position of the column having a maybe colorized item.
		$formatter->display_items( $display, array_values( $fields ) );

		// Display the copyright notice.
		if ( ! $this->porcelain && 'table' === $format && ! empty( $this->get_copyright_notice() ) ) {
			WP_CLI::log( $this->get_copyright_notice() );
		}
	}

	/**
	 * Init Scanner API service
	 *
	 * @param array $assoc_args Array of command arguments.
	 *
	 * @since 2.0.0
	 */
	private function init( $assoc_args ) {
		if ( defined( 'VULN_API_PROVIDER' ) && 'patchstack' === VULN_API_PROVIDER ) {
			$this->service = new Vuln_Patchstack_Service( $assoc_args );
		} elseif ( defined( 'VULN_API_PROVIDER' ) && 'wordfence' === VULN_API_PROVIDER ) {
			$this->service = new Vuln_Wordfence_Service( $assoc_args );
		} else {
			$this->service = new Vuln_WPScan_Service( $assoc_args );
		}
	}

	/**
	 * Do WordPress core check
	 *
	 * @return void
	 */
	private function do_wordpress() {

		$singular_type = 'wordpress'; // phpcs:ignore WordPress.WP.CapitalPDangit.Misspelled
		$plural_type   = 'wordpresses';

		$assoc_args = "assoc_args_{$singular_type}";

		$display     = $this->service->check_wordpress();
		$update_list = $this->extract_updatable_items( $display );

		$display_format = isset( $this->{$assoc_args}['format'] ) && ! empty( $this->{$assoc_args}['format'] ) ? $this->{$assoc_args}['format'] : 'table';
		$display        = $this->format_data_for_return( $display_format, $display );

		// Collect copyrights data.
		$this->collect_copyrights_data( $display );

		// Pretty print.
		if ( ! $this->porcelain ) {
			$fields = array(
				'name'              => true,
				'installed version' => false,
				'status'            => false,
				'introduced in'     => false,
				'fixed in'          => false,
				'severity'          => false,
			);
			if ( $this->reference ) {
				$fields['reference'] = false;
			}

			$formatter = new \WP_CLI\Formatter(
				$this->$assoc_args,
				array_keys( $fields ),
				$plural_type
			);

			// Add second array parameter to indicate the position of the column having a maybe colorized item.
			$formatter->display_items(
				$display,
				array_values( $fields )
			);
			// Improve readeability: force new line.
			if ( 'ids' === $display_format ) {
				WP_CLI::log( '' );
			}
		} elseif ( $update_list ) {
			WP_CLI::log( implode( ' ', $update_list ) );
			die;
		}
	}

	/**
	 * Helper. Call worker for plugin tasks
	 *
	 * @return void
	 */
	private function do_plugins() {
		$this->do_thing( 'plugin' );
	}

	/**
	 * Helper. Call worker for theme tasks
	 *
	 * @return void
	 */
	private function do_themes() {
		$this->do_thing( 'theme' );
	}

	/**
	 * Worker. Do what's needed for plugins/themes
	 * Display table, or if --porcelain, display only updatable slugs
	 *
	 * @param string|array $type The "thing" we're checking.
	 *                           If string, it's pluralized with an "s"
	 *                           If array, should be [ single, plural ].
	 *
	 * @return void
	 */
	private function do_thing( $type ) {
		list( $singular_type, $plural_type ) = $this->get_slugs( $type );

		$assoc_args = "assoc_args_{$singular_type}";

		$display     = $this->service->check_thing( $singular_type );
		$update_list = $this->extract_updatable_items( $display );

		$display_format = isset( $this->{$assoc_args}['format'] ) && ! empty( $this->{$assoc_args}['format'] ) ? $this->{$assoc_args}['format'] : 'table';
		$display        = $this->format_data_for_return( $display_format, $display );

		// Collect copyrights data.
		$this->collect_copyrights_data( $display );

		// Pretty print.
		if ( ! $this->porcelain ) {

			$fields = array(
				'name'              => true,
				'installed version' => false,
				'status'            => false,
				'introduced in'     => false,
				'fixed in'          => false,
				'severity'          => false,
			);
			if ( $this->reference ) {
				$fields['reference'] = false;
			}

			$formatter = new \WP_CLI\Formatter(
				$this->$assoc_args,
				array_keys( $fields ),
				$plural_type
			);

			// Add second array parameter to indicate the position of the column having a maybe colorized item.
			$formatter->display_items(
				$display,
				array_values( $fields )
			);

			// if plugins need updating, do or tell the user.
			if ( $update_list ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf
				// it would be nice to show this, but we'd need to rewrite the unit test
				//              $update_list = implode( ' ', $update_list );
				//              WP_CLI::log( "Run `wp $singular_type update $update_list`" );
			} elseif ( ( isset( $this->{$assoc_args}['format'] ) && 'json' !== $this->{$assoc_args}['format'] ) ) {
				WP_CLI::log( 'Nothing to update' );
			}

			// Improve readeability: force new line.
			if ( 'ids' === $display_format ) {
				WP_CLI::log( '' );
			}
		} elseif ( $update_list ) {
			WP_CLI::log( implode( ' ', $update_list ) );
			die;
		}
	}

	/**
	 * Pull updatable items from Formatter-ready array
	 *
	 * @param array $data Formatter-ready data.
	 *
	 * @return array Simple array of plugin/theme slugs needing to be updated
	 */
	private function extract_updatable_items( $data ) {
		$update_list = wp_list_pluck( wp_list_filter( $data, array( 'action' => 'update' ) ), 'action', 'slug' );
		$update_list = array_keys( $update_list );

		return $update_list;
	}

	/**
	 * Get singular and plural slugs for given string or array
	 *
	 * @param string|array $singular_or_array If string, it's pluralized with an "s"
	 *                           If array, should be [ single, plural ].
	 *
	 * @return array
	 */
	private function get_slugs( $singular_or_array ) {
		if ( is_array( $singular_or_array ) ) {
			$singular_type = $singular_or_array[0];
			$plural_type   = $singular_or_array[1];
		} else {
			$singular_type = $singular_or_array;
			$plural_type   = "{$singular_or_array}s";
		}

		return array( $singular_type, $plural_type );
	}

	/**
	 * Do nagios output.
	 *
	 * @param array $things type (WordPress,plugin,theme).
	 */
	private function do_nagios_op( $things ) {
		if ( ! empty( $things ) && is_array( $things ) ) {

			$wp_list = 0;
			$pl_list = 0;
			$th_list = 0;

			// phpcs:ignore WordPress.WP.CapitalPDangit.Misspelled
			if ( in_array( 'wordpress', $things, true ) ) {
				$wp_vulns = $this->service->check_wordpress();
				if ( ! empty( $wp_vulns ) && is_array( $wp_vulns ) ) {
					foreach ( $wp_vulns as $wp_vuln ) {
						if ( isset( $wp_vuln['fixed in'] ) && 'n/a' !== $wp_vuln['fixed in'] ) {
							$wp_list ++;
						}
					}
				}
			}
			if ( in_array( 'plugin', $things, true ) ) {
				$plugins = $this->service->check_thing( 'plugin' );
				if ( ! empty( $plugins ) && is_array( $plugins ) ) {
					foreach ( $plugins as $plugin ) {
						if ( isset( $plugin['fixed in'] ) && 'n/a' !== $plugin['fixed in'] ) {
							$pl_list ++;
						}
					}
				}
			}
			if ( in_array( 'theme', $things, true ) ) {
				$themes = $this->service->check_thing( 'theme' );
				if ( ! empty( $themes ) && is_array( $themes ) ) {
					foreach ( $themes as $theme ) {
						if ( isset( $theme['fixed in'] ) && 'n/a' !== $theme['fixed in'] ) {
							$th_list ++;
						}
					}
				}
			}

			if ( empty( $wp_list ) && empty( $pl_list ) && empty( $th_list ) ) {
				WP_CLI::line( 'OK - no vulnerabilities found' );
				exit( 0 );
			} else {
				$message = sprintf(
					'CRITICAL - %d core, %d plugin and %d theme vulnerabilities found',
					$wp_list,
					$pl_list,
					$th_list
				);
				WP_CLI::line( $message );

				// Notify via mail.
				if ( ! empty( $this->mail ) ) {

					$site_url = site_url();
					$headers  = array();

					/*
					 * In CLI if $_SERVER['SERVER_NAME'] isn't set than it fails to send mail.
					 * Reason being, this property is used to create "From" mail header.
					 * If it's empty, we need to pass that in header.
					 */
					if ( empty( $_SERVER['SERVER_NAME'] ) ) {
						$sitename = $site_url;
						if ( substr( $sitename, 0, 8 ) === 'https://' ) {
							$sitename = substr( $sitename, 8 );
						}
						if ( substr( $sitename, 0, 7 ) === 'http://' ) {
							$sitename = substr( $sitename, 7 );
						}
						if ( substr( $sitename, 0, 4 ) === 'www.' ) {
							$sitename = substr( $sitename, 4 );
						}

						$from_email = 'wordpress@' . $sitename;

						$headers[] = "From:{$from_email}";
					}

					$subject = 'Vulnerabilities found in ' . $site_url;
					if ( ! wp_mail( $this->mail, $subject, $message, $headers ) ) {
						WP_CLI::line( 'Not able to send mail.' );
					}
				}

				exit( 2 );
			}
		}
	}

	/**
	 * Format data before output based on --format parameter passed to command.
	 *
	 * @param string $output_format  One of table, csv, json, count, ids, yaml.
	 * @param array  $data           Array having report details.
	 * @return array $applied_format Array for correct output.
	 */
	protected function format_data_for_return( $output_format, $data ) {

		switch ( $output_format ) {
			case 'ids':
				$applied_format = array_filter( wp_list_pluck( $data, 'id' ) );
				break;
			default:
				$applied_format = $data;
		}

		return $applied_format;
	}

	/**
	 * Get the credit wordings for the API provider.
	 *
	 * @return string
	 */
	private function get_api_provider_credit() {
		$api_providers = array(
			'wpscan'     => 'WPScan',
			'patchstack' => 'Patchstack',
			'wordfence'  => 'Wordfence Intelligence',
		);

		$api_provider = $api_providers['wpscan'];
		if ( defined( 'VULN_API_PROVIDER' ) ) {
			$api_provider = $api_providers[ VULN_API_PROVIDER ];
		}

		/* translators: %s is API provider service. */
		return sprintf( __( 'Vulnerability API Provider: %s', 'wpcli-vulnerability-scanner' ), $api_provider );
	}

	/**
	 * Collect copyright notices from the API response.
	 *
	 * @param array $data
	 * @return void
	 */
	private function collect_copyrights_data( $data ) {
		if ( empty( $data ) || ! is_array( $data ) ) {
			return;
		}
		foreach ( $data as $value ) {
			if ( isset( $value['copyrights'] ) && ! empty( $value['copyrights'] ) ) {
				foreach ( $value['copyrights'] as $key => $copyright ) {
					if ( 'message' === $key || isset( $this->copyrights[ $key ] ) ) {
						continue;
					}
					if ( isset( $copyright->notice ) && ! empty( $copyright->notice ) ) {
						$this->copyrights[ $key ] = $copyright->notice;
					}
				}
			}
		}
	}

	/**
	 * Get the copyright notice to show in the footer.
	 *
	 * @return string
	 */
	private function get_copyright_notice() {
		$notice = '';
		if ( ! empty( $this->copyrights ) ) {
			/* translators: %s is Copyright notice */
			$notice = sprintf( __( 'Copyrights: %s', 'wpcli-vulnerability-scanner' ), implode( ', ', $this->copyrights ) );
		}

		return $notice;
	}
}
