diff --git a/docs/checks.md b/docs/checks.md index 9846dc003..c3b985200 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -19,6 +19,7 @@ | plugin_review_phpcs | plugin_repo | Runs PHP_CodeSniffer to detect certain best practices plugins should follow for submission on WordPress.org, including heredoc usage detection. | [Learn more](https://developer.wordpress.org/plugins/plugin-basics/best-practices/) | | direct_db_queries | security, plugin_repo | Checks the usage of direct database queries, which should be avoided. | [Learn more](https://developer.wordpress.org/apis/database/) | | direct_db | security, plugin_repo | Checks the escaping in direct database queries. | [Learn more](https://developer.wordpress.org/apis/database/) | +| public_content_export | security | Detects when post content is exported through a public surface without an apparent access-control guard. | [Learn more](https://developer.wordpress.org/plugins/wordpress-org/plugin-guidelines/) | | performant_wp_query_params | performance | Checks for potentially slow database queries when using WP_Query | [Learn more](https://developer.wordpress.org/apis/database/) | | enqueued_scripts_in_footer | performance | Checks whether a loading strategy is explicitly set for JavaScript files, as loading scripts in the footer is usually desired. | [Learn more](https://developer.wordpress.org/plugins/) | | enqueued_resources | plugin_repo, performance | Checks whether scripts and styles are properly enqueued using the recommended way. | [Learn more](https://developer.wordpress.org/plugins/) | diff --git a/includes/Checker/Checks/Security/Public_Content_Export_Check.php b/includes/Checker/Checks/Security/Public_Content_Export_Check.php new file mode 100644 index 000000000..f2c554311 --- /dev/null +++ b/includes/Checker/Checks/Security/Public_Content_Export_Check.php @@ -0,0 +1,128 @@ + 'php', + 'standard' => 'PluginCheck', + 'sniffs' => 'PluginCheck.Security.PublicContentExport', + ); + } + + /** + * Gets the description for the check. + * + * @since 2.1.0 + * + * @return string Description. + */ + public function get_description(): string { + return __( 'Detects when post content is exported through a public surface without an apparent access-control guard.', 'plugin-check' ); + } + + /** + * Gets the documentation URL for the check. + * + * @since 2.1.0 + * + * @return string The documentation URL. + */ + public function get_documentation_url(): string { + return __( 'https://developer.wordpress.org/plugins/wordpress-org/plugin-guidelines/#wordpress-org-plugin-guidelines', 'plugin-check' ); + } + + /** + * Amends the given result for a plugin context, customizing the message + * for post-content export warnings. + * + * @since 2.1.0 + * + * @param Check_Result $result The check result to amend. + * @param bool $error Whether this is an error (true) or a warning (false). + * @param string $message The original message from the sniff. + * @param string $code The sniff error/warning code. + * @param string $file The file where the issue was found. + * @param int $line The line number. + * @param int $column The column number. + * @param string $docs Documentation URL override. + * @param int $severity Severity level (1-9 per PHPCS convention). + */ + protected function add_result_message_for_file( $result, $error, $message, $code, $file, $line = 0, $column = 0, $docs = '', $severity = 5 ) { + // All findings from this check are advisory warnings. + parent::add_result_message_for_file( + $result, + false, + $message, + $code, + $file, + $line, + $column, + $docs, + $severity + ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index d4d5c548d..396fe4a7a 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -94,6 +94,7 @@ private function register_default_checks() { 'localhost' => new Checks\Plugin_Repo\Localhost_Check(), 'no_unfiltered_uploads' => new Checks\Plugin_Repo\No_Unfiltered_Uploads_Check(), 'trademarks' => new Checks\Plugin_Repo\Trademarks_Check(), + 'public_content_export' => new Checks\Security\Public_Content_Export_Check(), 'non_blocking_scripts' => new Checks\Performance\Non_Blocking_Scripts_Check(), 'offloading_files' => new Checks\Plugin_Repo\Offloading_Files_Check(), 'write_file' => new Checks\Plugin_Repo\Write_File_Check(), diff --git a/phpcs-sniffs/PluginCheck/Sniffs/Security/PublicContentExportSniff.php b/phpcs-sniffs/PluginCheck/Sniffs/Security/PublicContentExportSniff.php new file mode 100644 index 000000000..f91aedaff --- /dev/null +++ b/phpcs-sniffs/PluginCheck/Sniffs/Security/PublicContentExportSniff.php @@ -0,0 +1,527 @@ + Key is function name, value is parameter position. + */ + private $content_param_positions = array( + 'file_put_contents' => 2, + 'fwrite' => 2, + 'fputs' => 2, + ); + + /** + * List of function names that indicate post content is being read. + * + * @since 2.1.0 + * + * @var array + */ + private $post_content_functions = array( + 'get_the_content' => true, + 'the_content' => true, + 'get_the_excerpt' => true, + 'the_excerpt' => true, + 'get_post_field' => true, + 'apply_filters' => true, + ); + + /** + * WordPress filter names that imply content is being rendered for export. + * + * When apply_filters is called with one of these, it is a strong signal + * that post content is being surfaced through an alternative channel. + * + * @since 2.1.0 + * + * @var array + */ + private $content_filters = array( + 'the_content' => true, + 'the_content_export' => true, + 'the_content_feed' => true, + 'the_content_rss' => true, + 'the_excerpt' => true, + 'the_excerpt_export' => true, + 'the_excerpt_rss' => true, + ); + + /** + * List of function names that suggest an access-control guard is present. + * + * @since 2.1.0 + * + * @var array + */ + private $guard_functions = array( + 'post_password_required' => true, + 'current_user_can' => true, + 'is_post_type_viewable' => true, + 'is_user_logged_in' => true, + ); + + /** + * Key list: override the parent's empty target_functions approach. + * + * {@inheritDoc} + * + * @since 2.1.0 + * + * @var array + */ + protected $target_functions = array( + 'file_put_contents' => true, + 'fwrite' => true, + 'fputs' => true, + ); + + /** + * Look for post-content references inside the content parameter of a + * matched export function. + * + * @since 2.1.0 + * + * @param int $stackPtr Position of the function name token. + * @param string $group_name The group name that was matched. + * @param string $matched_content The matched function name (lowercase). + * @param array $parameters Parsed parameter information. + * + * @return void + */ + public function process_parameters( $stackPtr, $group_name, $matched_content, $parameters ) { + $param_position = isset( $this->content_param_positions[ $matched_content ] ) + ? $this->content_param_positions[ $matched_content ] + : 1; + + $content_param = PassedParameters::getParameterFromStack( $parameters, $param_position, array() ); + + if ( false === $content_param ) { + return; + } + + // Scan the content parameter tokens for post-content signals. + if ( ! $this->param_contains_post_content( $content_param['start'], $content_param['end'], $stackPtr ) ) { + return; + } + + // Check whether an access-control guard exists in the same function scope. + if ( $this->has_access_guard_in_scope( $stackPtr ) ) { + return; + } + + MessageHelper::addMessage( + $this->phpcsFile, + 'Post content is exported without an apparent access-control check. Ensure you guard against password-protected or restricted content by checking post_password_required() and providing a filter that allows site owners to veto the export.', + $content_param['start'], + false, + 'PostContentExport' + ); + } + + /** + * Scans tokens between start and end for post-content signal patterns. + * + * Looks for function calls like get_the_content(), the_content(), and + * for property access like $post->post_content. Also traces back + * variable assignments in the same scope to find post-content sources. + * + * @since 2.1.0 + * + * @param int $start Start token pointer. + * @param int $end End token pointer. + * @param int $stackPtr Position of the export function call (for scope lookup). + * + * @return bool True if the parameter references post content. + */ + private function param_contains_post_content( $start, $end, $stackPtr ) { + // First, check for direct post-content signals in the parameter range. + if ( $this->token_range_has_post_content_signal( $start, $end ) ) { + return true; + } + + // No direct signal: collect variables and trace their assignments. + $found_variables = array(); + for ( $i = $start; $i <= $end; $i++ ) { + if ( T_VARIABLE === $this->tokens[ $i ]['code'] ) { + $found_variables[ $this->tokens[ $i ]['content'] ] = true; + } + } + + if ( empty( $found_variables ) ) { + return false; + } + + $scope_opener = $this->get_scope_opener( $stackPtr ); + $scope_start = ( null !== $scope_opener ) ? $scope_opener + 1 : 0; + + $visited = array(); + + return $this->variable_assigned_from_post_content( $found_variables, $stackPtr, $scope_start, $visited ); + } + + /** + * Scans a token range for direct post-content signals only. + * + * Detects function calls (get_the_content, the_content, etc.) and + * property access ($post->post_content) without following variables. + * + * @since 2.1.0 + * + * @param int $start Start token pointer. + * @param int $end End token pointer. + * + * @return bool True if a direct post-content signal is found. + */ + private function token_range_has_post_content_signal( $start, $end ) { + for ( $i = $start; $i <= $end; $i++ ) { + $code = $this->tokens[ $i ]['code']; + + // Detect calls to content-reading functions like the_content(). + if ( T_STRING === $code ) { + $func_lower = strtolower( $this->tokens[ $i ]['content'] ); + + if ( ! isset( $this->post_content_functions[ $func_lower ] ) ) { + continue; + } + + $next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), null, true ); + if ( false === $next_non_empty || T_OPEN_PARENTHESIS !== $this->tokens[ $next_non_empty ]['code'] ) { + continue; + } + + // For apply_filters, verify the filter name is content-related. + if ( 'apply_filters' === $func_lower ) { + if ( $this->is_content_filter_call( $next_non_empty ) ) { + return true; + } + continue; + } + + // For get_post_field, check if the first argument is 'post_content'. + if ( 'get_post_field' === $func_lower && ! $this->is_post_content_field_arg( $next_non_empty ) ) { + continue; + } + + return true; + } + + // Property access: $post->post_content, $some_obj->post_content. + if ( T_OBJECT_OPERATOR === $code ) { + $next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), null, true ); + if ( false !== $next_non_empty + && T_STRING === $this->tokens[ $next_non_empty ]['code'] + && 'post_content' === strtolower( $this->tokens[ $next_non_empty ]['content'] ) + ) { + return true; + } + } + } + + return false; + } + + /** + * Checks whether any of the given variables was assigned from a + * post-content source within the current scope. + * + * Traces variable assignments backward, re-scanning each assignment + * expression for direct post-content signals and following further + * nested variables up to a safe depth. + * + * @since 2.1.0 + * + * @param array $variables Map of variable name => true to check. + * @param int $stackPtr The function call pointer (look-back limit). + * @param int $scope_start First token inside the enclosing scope. + * @param array $visited Already-visited variable names (prevent loops). + * + * @return bool True if a variable was assigned from post content. + */ + private function variable_assigned_from_post_content( $variables, $stackPtr, $scope_start, &$visited ) { + foreach ( $variables as $var_name => $unused ) { + if ( isset( $visited[ $var_name ] ) ) { + continue; + } + $visited[ $var_name ] = true; + + $range = $this->find_variable_assignment( $var_name, $stackPtr, $scope_start ); + if ( empty( $range ) ) { + continue; + } + + // Direct signal in the assignment expression. + if ( $this->token_range_has_post_content_signal( $range['start'], $range['end'] ) ) { + return true; + } + + // Follow nested variables in the assignment expression. + $nested = array(); + for ( $i = $range['start']; $i <= $range['end']; $i++ ) { + if ( T_VARIABLE === $this->tokens[ $i ]['code'] ) { + $nested_var = $this->tokens[ $i ]['content']; + if ( ! isset( $visited[ $nested_var ] ) ) { + $nested[ $nested_var ] = true; + } + } + } + + if ( ! empty( $nested ) + && $this->variable_assigned_from_post_content( $nested, $stackPtr, $scope_start, $visited ) + ) { + return true; + } + } + + return false; + } + + /** + * Finds the most recent assignment expression for a variable in the + * current scope, looking backward from the export function call. + * + * @since 2.1.0 + * + * @param string $var_name Variable name (including $ prefix). + * @param int $stackPtr Position of the export function call. + * @param int $scope_start First token index inside the enclosing scope. + * + * @return array Empty array if not found, or array with 'start' and 'end' keys. + */ + private function find_variable_assignment( $var_name, $stackPtr, $scope_start ) { + $function_scope_ptr = $this->get_scope_opener( $stackPtr ); + + for ( $i = $stackPtr - 1; $i >= $scope_start; $i-- ) { + if ( T_VARIABLE !== $this->tokens[ $i ]['code'] + || $var_name !== $this->tokens[ $i ]['content'] + ) { + continue; + } + + // Skip variables declared in a nested scope (not the export call's scope). + if ( null !== $function_scope_ptr ) { + $var_scope = $this->get_scope_opener( $i ); + if ( $var_scope !== $function_scope_ptr ) { + continue; + } + } + + $next_non_empty = $this->phpcsFile->findNext( + Tokens::$emptyTokens, + ( $i + 1 ), + null, + true + ); + + if ( false === $next_non_empty || T_EQUAL !== $this->tokens[ $next_non_empty ]['code'] ) { + continue; + } + + $assignment_end = $this->phpcsFile->findEndOfStatement( $next_non_empty ); + + if ( false === $assignment_end || $assignment_end >= $stackPtr ) { + continue; + } + + return array( + 'start' => $next_non_empty + 1, + 'end' => $assignment_end, + ); + } + + return array(); + } + + /** + * Checks whether an apply_filters call uses a content-related filter name. + * + * The first argument to apply_filters is the filter tag. If it matches + * the_content or similar, this is a content-export signal. + * + * @since 2.1.0 + * + * @param int $open_paren_ptr Position of the opening parenthesis. + * + * @return bool True if the filter is content-related. + */ + private function is_content_filter_call( $open_paren_ptr ) { + $closer = isset( $this->tokens[ $open_paren_ptr ]['parenthesis_closer'] ) + ? $this->tokens[ $open_paren_ptr ]['parenthesis_closer'] + : null; + + if ( null === $closer ) { + return false; + } + + // Find the first non-empty token after the opening paren. + $first_arg_start = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $open_paren_ptr + 1 ), $closer, true ); + if ( false === $first_arg_start ) { + return false; + } + + // Build the filter name string from the first argument's tokens. + $filter_name = ''; + $paren_depth = 0; + + for ( $i = $first_arg_start; $i < $closer; $i++ ) { + if ( T_OPEN_PARENTHESIS === $this->tokens[ $i ]['code'] ) { + ++$paren_depth; + continue; + } + if ( T_CLOSE_PARENTHESIS === $this->tokens[ $i ]['code'] ) { + if ( $paren_depth > 0 ) { + --$paren_depth; + continue; + } + break; + } + if ( T_COMMA === $this->tokens[ $i ]['code'] && 0 === $paren_depth ) { + break; + } + + $filter_name .= $this->tokens[ $i ]['content']; + } + + $filter_name = trim( strtolower( $filter_name ), "'\" \t\n\r\0\x0B" ); + + return isset( $this->content_filters[ $filter_name ] ); + } + + /** + * Checks whether a get_post_field call uses 'post_content' as the first argument. + * + * @since 2.1.0 + * + * @param int $open_paren_ptr Position of the opening parenthesis. + * + * @return bool True if the first argument is 'post_content'. + */ + private function is_post_content_field_arg( $open_paren_ptr ) { + $closer = isset( $this->tokens[ $open_paren_ptr ]['parenthesis_closer'] ) + ? $this->tokens[ $open_paren_ptr ]['parenthesis_closer'] + : null; + + if ( null === $closer ) { + return false; + } + + $first_arg_start = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $open_paren_ptr + 1 ), $closer, true ); + if ( false === $first_arg_start ) { + return false; + } + + $content = trim( $this->tokens[ $first_arg_start ]['content'], "'\" \t\n\r\0\x0B" ); + + return 'post_content' === $content; + } + + /** + * Determines whether an access-control guard exists in the same function + * scope as the export call. + * + * Walks backward from the export call through the enclosing function body + * looking for calls to post_password_required(), current_user_can(), or + * similar guard functions. A guard within the same scope is treated as + * evidence the developer has considered access control, suppressing the + * advisory warning. + * + * @since 2.1.0 + * + * @param int $stackPtr Position of the export function call. + * + * @return bool True if a guard is found. + */ + private function has_access_guard_in_scope( $stackPtr ) { + $scope_opener = $this->get_scope_opener( $stackPtr ); + + if ( null === $scope_opener ) { + return false; + } + + for ( $i = $stackPtr - 1; $i > $scope_opener; $i-- ) { + if ( T_STRING !== $this->tokens[ $i ]['code'] ) { + continue; + } + + $func_lower = strtolower( $this->tokens[ $i ]['content'] ); + + if ( ! isset( $this->guard_functions[ $func_lower ] ) ) { + continue; + } + + $next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), null, true ); + if ( false !== $next_non_empty && T_OPEN_PARENTHESIS === $this->tokens[ $next_non_empty ]['code'] ) { + return true; + } + } + + return false; + } + + /** + * Returns the pointer to the opening brace of the enclosing function scope. + * + * @since 2.1.0 + * + * @param int $stackPtr A pointer within the function body. + * + * @return int|null Pointer to the scope opener, or null if not found. + */ + private function get_scope_opener( $stackPtr ) { + if ( empty( $this->tokens[ $stackPtr ]['conditions'] ) ) { + return null; + } + + $conditions = array_reverse( $this->tokens[ $stackPtr ]['conditions'], true ); + + foreach ( $conditions as $condition_ptr => $condition_code ) { + if ( in_array( $condition_code, array( T_FUNCTION, T_CLOSURE, T_FN ), true ) ) { + return isset( $this->tokens[ $condition_ptr ]['scope_opener'] ) + ? $this->tokens[ $condition_ptr ]['scope_opener'] + : null; + } + } + + return null; + } +} diff --git a/phpcs-sniffs/PluginCheck/Tests/Security/PublicContentExportUnitTest.inc b/phpcs-sniffs/PluginCheck/Tests/Security/PublicContentExportUnitTest.inc new file mode 100644 index 000000000..edbcc573f --- /dev/null +++ b/phpcs-sniffs/PluginCheck/Tests/Security/PublicContentExportUnitTest.inc @@ -0,0 +1,128 @@ +post_content property access. +function trigger_post_content_property() { + $post = get_post( 42 ); + file_put_contents( '/tmp/post.txt', $post->post_content ); +} + +// Warning: apply_filters with the_content filter into file write. +function trigger_filter_the_content() { + $post = get_post( 42 ); + $rendered = apply_filters( 'the_content', $post->post_content ); + file_put_contents( '/tmp/rendered.html', $rendered ); +} + +// Warning: fwrite with get_post_field('post_content'). +function trigger_get_post_field() { + $fp = fopen( '/tmp/field.txt', 'w' ); + fwrite( $fp, get_post_field( 'post_content', 42 ) ); + fclose( $fp ); +} + +// -- Suppressed by guard (no warning expected) -- + +// Suppressed: post_password_required() guard before file write. +function guarded_password_check() { + $post = get_post( 42 ); + $content = the_content(); + if ( post_password_required( $post ) ) { + return; + } + file_put_contents( '/tmp/export.html', $content ); +} + +// Suppressed: current_user_can() guard before file write. +function guarded_cap_check() { + $content = the_content(); + if ( current_user_can( 'edit_posts' ) ) { + file_put_contents( '/tmp/admin-export.html', $content ); + } +} + +// Suppressed: is_post_type_viewable() guard before file write. +function guarded_viewable_check() { + $post = get_post( 42 ); + if ( ! is_post_type_viewable( $post->post_type ) ) { + return; + } + file_put_contents( '/tmp/public-post.html', get_the_content( null, false, $post ) ); +} + +// Suppressed: is_user_logged_in() guard before file write. +function guarded_login_check() { + if ( ! is_user_logged_in() ) { + return; + } + file_put_contents( '/tmp/members-export.html', get_the_content() ); +} + +// -- Non-matches (no warning expected) -- + +// No warning: file_put_contents without post content reference. +function safe_no_content() { + file_put_contents( '/tmp/log.txt', 'Log entry' ); +} + +// No warning: fwrite with non-content data. +function safe_static_data() { + $fp = fopen( '/tmp/data.txt', 'w' ); + $data = 'static page content'; + fwrite( $fp, $data ); + fclose( $fp ); +} + +// No warning: get_post_field with non-post_content field. +function safe_non_content_field() { + $fp = fopen( '/tmp/title.txt', 'w' ); + fwrite( $fp, get_post_field( 'post_title', 42 ) ); + fclose( $fp ); +} + +// No warning: apply_filters with a non-content filter name. +function safe_custom_filter() { + $data = 'arbitrary data'; + $value = apply_filters( 'my_custom_filter', $data ); + file_put_contents( '/tmp/custom.txt', $value ); +} + +// No warning: the_content() called standalone, not passed to a file write. +function safe_standalone_content() { + the_content(); +} + +// No warning: $post->post_title is not post_content. +function safe_post_title() { + $post = get_post( 42 ); + file_put_contents( '/tmp/title.txt', $post->post_title ); +} diff --git a/phpcs-sniffs/PluginCheck/Tests/Security/PublicContentExportUnitTest.php b/phpcs-sniffs/PluginCheck/Tests/Security/PublicContentExportUnitTest.php new file mode 100644 index 000000000..9ffb66c60 --- /dev/null +++ b/phpcs-sniffs/PluginCheck/Tests/Security/PublicContentExportUnitTest.php @@ -0,0 +1,67 @@ + => + */ + public function getErrorList() { + return array(); + } + + /** + * Returns the lines where warnings should occur. + * + * @return array => + */ + public function getWarningList() { + return array( + 17 => 1, // file_put_contents with the_content(). + 24 => 1, // fwrite with get_the_content(). + 30 => 1, // fputs with get_the_excerpt(). + 36 => 1, // file_put_contents with $post->post_content. + 43 => 1, // apply_filters('the_content') into file_put_contents. + 49 => 1, // fwrite with get_post_field('post_content'). + ); + } + + /** + * Returns the fully qualified class name (FQCN) of the sniff. + * + * @return string The fully qualified class name of the sniff. + */ + protected function get_sniff_fqcn() { + return PublicContentExportSniff::class; + } + + /** + * Sets the parameters for the sniff. + * + * No additional parameters needed for this sniff. + * + * @throws \RuntimeException If unable to set the ruleset parameters required for the test. + * + * @param Sniff $sniff The sniff being tested. + */ + public function set_sniff_parameters( Sniff $sniff ) { + } +} diff --git a/phpcs-sniffs/PluginCheck/ruleset.xml b/phpcs-sniffs/PluginCheck/ruleset.xml index 3c5b50d2d..6a635269c 100644 --- a/phpcs-sniffs/PluginCheck/ruleset.xml +++ b/phpcs-sniffs/PluginCheck/ruleset.xml @@ -15,6 +15,7 @@ + diff --git a/tests/phpunit/testdata/plugins/test-plugin-public-content-export-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-public-content-export-with-errors/load.php new file mode 100644 index 000000000..95fd9f255 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-public-content-export-with-errors/load.php @@ -0,0 +1,60 @@ +post_content. +// Expected warning: PostContentExport. +function test_export_post_content_property() { + $post = get_post( 42 ); + file_put_contents( '/tmp/post.txt', $post->post_content ); +} + +// Trigger: file_put_contents with apply_filters('the_content'). +// Expected warning: PostContentExport. +function test_export_with_filter_the_content() { + $post = get_post( 42 ); + $rendered = apply_filters( 'the_content', $post->post_content ); + file_put_contents( '/tmp/rendered.html', $rendered ); +} + +// Trigger: fwrite with get_post_field('post_content'). +// Expected warning: PostContentExport. +function test_export_with_get_post_field() { + $fp = fopen( '/tmp/field.txt', 'w' ); + fwrite( $fp, get_post_field( 'post_content', 42 ) ); + fclose( $fp ); +} diff --git a/tests/phpunit/testdata/plugins/test-plugin-public-content-export-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-public-content-export-without-errors/load.php new file mode 100644 index 000000000..d745fecec --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-public-content-export-without-errors/load.php @@ -0,0 +1,74 @@ +post_content ); +} + +// Safe: file write with current_user_can guard. +function test_guarded_with_cap_check() { + $post = get_post( 42 ); + if ( ! current_user_can( 'read_post', $post->ID ) ) { + return; + } + file_put_contents( '/tmp/member-export.html', $post->post_content ); +} + +// Safe: file write with is_post_type_viewable guard. +function test_guarded_with_viewable_check() { + $post = get_post( 42 ); + if ( ! is_post_type_viewable( $post->post_type ) ) { + return; + } + file_put_contents( '/tmp/public-export.html', get_the_content( null, false, $post ) ); +} + +// Safe: file write with is_user_logged_in guard. +function test_guarded_with_login_check() { + if ( ! is_user_logged_in() ) { + return; + } + file_put_contents( '/tmp/logged-in-export.html', get_the_content() ); +} + +// Safe: the_content() used standalone, not passed to file write. +function test_standalone_content() { + the_content(); +} + +// Safe: fwrite with non-post-content property. +function test_non_content_property() { + $post = get_post( 42 ); + $fp = fopen( '/tmp/title.txt', 'w' ); + fwrite( $fp, $post->post_title ); + fclose( $fp ); +} + +// Safe: get_post_field with non-post_content field. +function test_non_content_field() { + $fp = fopen( '/tmp/field.txt', 'w' ); + fwrite( $fp, get_post_field( 'post_title', 42 ) ); + fclose( $fp ); +} diff --git a/tests/phpunit/tests/Checker/Checks/Public_Content_Export_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Public_Content_Export_Check_Tests.php new file mode 100644 index 000000000..69ecb3204 --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/Public_Content_Export_Check_Tests.php @@ -0,0 +1,76 @@ +run( $check_result ); + + $errors = $check_result->get_errors(); + $warnings = $check_result->get_warnings(); + + // This is an advisory check — all findings are warnings, never errors. + $this->assertEmpty( $errors ); + $this->assertSame( 0, $check_result->get_error_count() ); + + // Should have warnings for post content export patterns. + $this->assertNotEmpty( $warnings ); + $this->assertArrayHasKey( 'load.php', $warnings ); + + // Verify the expected warning code is present. + $warning_codes = array(); + $warning_count = 0; + foreach ( $warnings['load.php'] as $line => $columns ) { + foreach ( $columns as $column => $messages ) { + foreach ( $messages as $message ) { + $warning_codes[] = $message['code']; + ++$warning_count; + } + } + } + + $this->assertContains( 'PluginCheck.Security.PublicContentExport.PostContentExport', $warning_codes ); + $this->assertSame( 6, $warning_count ); + } + + public function test_run_without_errors() { + $check = new Public_Content_Export_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-public-content-export-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + $warnings = $check_result->get_warnings(); + + // Should have no errors or warnings when access-control guards are present. + $this->assertEmpty( $errors ); + $this->assertEmpty( $warnings ); + $this->assertSame( 0, $check_result->get_error_count() ); + $this->assertSame( 0, $check_result->get_warning_count() ); + } + + public function test_get_description() { + $check = new Public_Content_Export_Check(); + $this->assertNotEmpty( $check->get_description() ); + } + + public function test_get_documentation_url() { + $check = new Public_Content_Export_Check(); + $url = $check->get_documentation_url(); + $this->assertNotEmpty( $url ); + $this->assertStringContainsString( 'developer.wordpress.org', $url ); + } +}