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 );
+ }
+}