From 7dd3eeaa8ab0d3b42797bcb076d13c1696a20772 Mon Sep 17 00:00:00 2001 From: Javier Casares Date: Mon, 8 Jun 2026 12:31:51 +0000 Subject: [PATCH] v1.1.1 --- .phpunit.result.cache | 1 + CHANGELOG.md | 59 ++ changelog.txt | 53 ++ class-robotstxt-updater.php | 465 ++++++++++ docs/audit-pre-deploy-1.1.1.md | 90 ++ docs/db-migrations.md | 9 + docs/deploy-checklist-1.1.1.md | 113 +++ docs/known-issues.md | 11 + .../robotstxt-documentation-markdown-es_ES.po | 845 ------------------ readme.txt | 38 +- ...tstxt-documentation-markdown-functions.php | 5 +- robotstxt-documentation-markdown-github.php | 6 +- robotstxt-documentation-markdown-map.php | 18 +- robotstxt-documentation-markdown.php | 35 +- update.json | 10 +- vendor/autoload.php | 7 +- vendor/composer/InstalledVersions.php | 45 +- vendor/composer/LICENSE | 2 + vendor/composer/autoload_classmap.php | 3 + vendor/composer/autoload_psr4.php | 3 +- vendor/composer/autoload_real.php | 10 +- vendor/composer/autoload_static.php | 45 +- vendor/composer/installed.json | 72 +- vendor/composer/installed.php | 28 +- vendor/composer/platform_check.php | 5 +- vendor/dflydev/dot-access-data/CHANGELOG.md | 74 ++ vendor/dflydev/dot-access-data/README.md | 158 ++++ vendor/dflydev/dot-access-data/composer.json | 67 ++ vendor/league/commonmark/CHANGELOG.md | 782 ++++++++++++++++ vendor/league/commonmark/README.md | 225 +++++ vendor/league/commonmark/composer.json | 129 +++ vendor/league/config/CHANGELOG.md | 42 + vendor/league/config/README.md | 153 ++++ vendor/league/config/composer.json | 69 ++ vendor/nette/schema/composer.json | 49 + vendor/nette/schema/license.md | 60 ++ vendor/nette/schema/readme.md | 536 +++++++++++ vendor/nette/schema/src/Schema/Context.php | 14 +- .../schema/src/Schema/DynamicParameter.php | 4 +- .../schema/src/Schema/Elements/AnyOf.php | 5 +- .../nette/schema/src/Schema/Elements/Base.php | 24 +- .../schema/src/Schema/Elements/Structure.php | 15 +- .../nette/schema/src/Schema/Elements/Type.php | 5 +- vendor/nette/schema/src/Schema/Expect.php | 18 +- vendor/nette/schema/src/Schema/Helpers.php | 13 +- vendor/nette/schema/src/Schema/Message.php | 32 +- vendor/nette/schema/src/Schema/Processor.php | 10 +- vendor/nette/schema/src/Schema/Schema.php | 4 +- .../schema/src/Schema/ValidationException.php | 29 +- vendor/nette/utils/.phpstorm.meta.php | 4 +- vendor/nette/utils/composer.json | 61 ++ vendor/nette/utils/license.md | 60 ++ vendor/nette/utils/readme.md | 55 ++ vendor/nette/utils/src/HtmlStringable.php | 9 +- .../utils/src/Iterators/CachingIterator.php | 37 +- vendor/nette/utils/src/Iterators/Mapper.php | 9 +- vendor/nette/utils/src/SmartObject.php | 7 +- vendor/nette/utils/src/StaticClass.php | 6 +- vendor/nette/utils/src/Translator.php | 6 +- vendor/nette/utils/src/Utils/ArrayHash.php | 10 +- vendor/nette/utils/src/Utils/ArrayList.php | 13 +- vendor/nette/utils/src/Utils/Arrays.php | 70 +- vendor/nette/utils/src/Utils/Callback.php | 27 +- vendor/nette/utils/src/Utils/DateTime.php | 10 +- vendor/nette/utils/src/Utils/FileInfo.php | 16 +- vendor/nette/utils/src/Utils/FileSystem.php | 26 +- vendor/nette/utils/src/Utils/Finder.php | 45 +- vendor/nette/utils/src/Utils/Floats.php | 4 +- vendor/nette/utils/src/Utils/Helpers.php | 14 +- vendor/nette/utils/src/Utils/Html.php | 261 +++--- vendor/nette/utils/src/Utils/Image.php | 173 ++-- vendor/nette/utils/src/Utils/ImageColor.php | 11 +- vendor/nette/utils/src/Utils/ImageType.php | 4 +- vendor/nette/utils/src/Utils/Iterables.php | 38 +- vendor/nette/utils/src/Utils/Json.php | 10 +- .../nette/utils/src/Utils/ObjectHelpers.php | 4 +- vendor/nette/utils/src/Utils/Paginator.php | 61 +- vendor/nette/utils/src/Utils/Process.php | 507 +++++++++++ vendor/nette/utils/src/Utils/Random.php | 4 +- vendor/nette/utils/src/Utils/Reflection.php | 36 +- .../utils/src/Utils/ReflectionMethod.php | 9 +- vendor/nette/utils/src/Utils/Strings.php | 79 +- vendor/nette/utils/src/Utils/Type.php | 82 +- vendor/nette/utils/src/Utils/Validators.php | 23 +- vendor/nette/utils/src/Utils/exceptions.php | 20 +- vendor/nette/utils/src/compatibility.php | 4 +- vendor/nette/utils/src/exceptions.php | 4 +- vendor/psr/event-dispatcher/.editorconfig | 15 + vendor/psr/event-dispatcher/.gitignore | 2 + vendor/psr/event-dispatcher/README.md | 6 + vendor/psr/event-dispatcher/composer.json | 26 + .../deprecation-contracts/CHANGELOG.md | 5 + .../symfony/deprecation-contracts/README.md | 26 + .../deprecation-contracts/composer.json | 35 + vendor/symfony/polyfill-php80/Php80.php | 2 +- vendor/symfony/polyfill-php80/README.md | 25 + vendor/symfony/polyfill-php80/composer.json | 37 + 97 files changed, 4912 insertions(+), 1581 deletions(-) create mode 100644 .phpunit.result.cache create mode 100644 CHANGELOG.md create mode 100644 class-robotstxt-updater.php create mode 100644 docs/audit-pre-deploy-1.1.1.md create mode 100644 docs/db-migrations.md create mode 100644 docs/deploy-checklist-1.1.1.md create mode 100644 docs/known-issues.md delete mode 100644 languages/robotstxt-documentation-markdown-es_ES.po create mode 100644 vendor/dflydev/dot-access-data/CHANGELOG.md create mode 100644 vendor/dflydev/dot-access-data/README.md create mode 100644 vendor/dflydev/dot-access-data/composer.json create mode 100644 vendor/league/commonmark/CHANGELOG.md create mode 100644 vendor/league/commonmark/README.md create mode 100644 vendor/league/commonmark/composer.json create mode 100644 vendor/league/config/CHANGELOG.md create mode 100644 vendor/league/config/README.md create mode 100644 vendor/league/config/composer.json create mode 100644 vendor/nette/schema/composer.json create mode 100644 vendor/nette/schema/license.md create mode 100644 vendor/nette/schema/readme.md create mode 100644 vendor/nette/utils/composer.json create mode 100644 vendor/nette/utils/license.md create mode 100644 vendor/nette/utils/readme.md create mode 100644 vendor/nette/utils/src/Utils/Process.php create mode 100644 vendor/psr/event-dispatcher/.editorconfig create mode 100644 vendor/psr/event-dispatcher/.gitignore create mode 100644 vendor/psr/event-dispatcher/README.md create mode 100644 vendor/psr/event-dispatcher/composer.json create mode 100644 vendor/symfony/deprecation-contracts/CHANGELOG.md create mode 100644 vendor/symfony/deprecation-contracts/README.md create mode 100644 vendor/symfony/deprecation-contracts/composer.json create mode 100644 vendor/symfony/polyfill-php80/README.md create mode 100644 vendor/symfony/polyfill-php80/composer.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache new file mode 100644 index 0000000..724a358 --- /dev/null +++ b/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":[],"times":{"Test_Plugin_Headers::plugin_file_exists":0.002,"Test_Plugin_Headers::plugin_name_header_is_present":0.001,"Test_Plugin_Headers::version_header_is_present_and_valid":0,"Test_Plugin_Headers::requires_at_least_header_is_present":0,"Test_Plugin_Headers::requires_php_header_is_present":0,"Test_Plugin_Headers::text_domain_matches_plugin_slug":0,"Test_Plugin_Headers::license_header_is_gpl3":0,"Test_Plugin_Headers::author_header_is_present":0,"Test_Plugin_Headers::readme_file_exists":0,"Test_Plugin_Headers::readme_has_stable_tag":0,"Test_Plugin_Headers::readme_has_requires_at_least":0,"Test_Plugin_Headers::readme_has_requires_php":0,"Test_Plugin_Headers::readme_has_license":0,"Test_Plugin_Headers::stable_tag_matches_plugin_version":0,"Test_Plugin_Headers::requires_at_least_is_consistent":0,"Test_Plugin_Headers::requires_php_is_consistent":0,"Test_Plugin_Headers::license_is_consistent":0}} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c446026 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +All notable changes to Documentation Markdown are documented in this file. + +For the WordPress.org–formatted changelog, see [`changelog.txt`](changelog.txt). + +## [1.1.1] — 2026-06-08 + +### Security +- CommonMark: disabled raw HTML passthrough (`html_input: strip`, `allow_unsafe_links: false`) — prevents stored XSS if an upstream repository is compromised +- Plugin settings option now stored with `autoload = false` — encrypted GitHub token no longer loaded on every WordPress request +- Replaced `serialize()` with `wp_json_encode()` in updater HMAC cache signature (eliminates object-injection risk surface) +- Added strict base64 length validation in `robotstxt_docmd_decrypt_token()` before IV extraction +- Added `base64_decode()` strict return-value check in `robotstxt_docmd_get_file_content()` + +### Fixed +- Admin notices now display results for all operations (mapping created/updated/deleted, sync complete, error messages); previously only "Settings saved" appeared +- `wp_update_post()` return value now checked during sync — silent failures previously reported as success +- Wrong textdomain `'robotstxt-smtp'` in updater class corrected to `'robotstxt-documentation-markdown'` + +### Changed +- `robotstxt-updater.php` renamed to `class-robotstxt-updater.php` (WordPress file-naming convention) +- `Requires PHP` header corrected to `8.0` (real minimum confirmed by PHPCompatibility scan — union types, `str_starts_with()`, `str_contains()`, `mixed` type are the binding constraints; no 8.1/8.2-specific features used) + +### Developer / Infrastructure +- Added `composer.json` with full `require-dev` tooling (PHPCS, PHPStan 2.x, PHPUnit 9.6, PHPCompatibility 10.0.0-alpha2, wp-compat, yoast/phpunit-polyfills) +- Added `phpstan.neon` (level 9), `.phpcs.xml` (WordPress standards), `bin/deploy.sh`, `phpunit.xml.dist` +- 17 PHPUnit plugin header tests added (`tests/PluginHeadersTest.php`) +- Added `docs/known-issues.md` and `docs/db-migrations.md` +- `class-robotstxt-updater.php`: PHPCS 0 errors, PHPStan level 9 0 errors +- PHPCompatibility updated to 10.0.0-alpha2 (PHP 8.x feature detection) + +## [1.1.0] — 2026-03-28 + +### Security +- Patched CVE-2026-33347 and CVE-2026-30838 (league/commonmark updated to 2.8.2) + +### Changed +- Access level changed from `manage_options` (administrator) to `edit_pages` (editor) + +### Fixed +- PHPStan level 9 compliance: zero errors across all plugin files +- `target_order` (menu_order) field now saved and applied end-to-end +- Type-safety improvements for all WordPress API returns +- Token decryption `false` return handled correctly +- Uninstall data cleanup narrows mixed option return before array access + +## [1.0.0] — 2026-01-26 + +### Added +- Initial release: GitHub → WordPress Markdown sync +- Encrypted GitHub token storage (AES-256-CBC) +- Custom Post Type `robotstxt_map` for mapping management +- Automatic cron sync (hourly, twice daily, daily) +- Manual on-demand sync via admin interface +- Markdown to HTML via CommonMark (league/commonmark) +- Multi-repository support +- Debug tools (visible when `WP_DEBUG` enabled) +- Full i18n support (es_ES bundled) diff --git a/changelog.txt b/changelog.txt index 79b8142..3dd2d2e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,58 @@ == Changelog == += 1.1.1 = + +_Release date: 2026-06-08_ + +**Highlights** + +* Security hardening: CommonMark raw HTML passthrough disabled, encrypted token no longer autoloaded, HMAC now uses `wp_json_encode()` instead of `serialize()` +* Bug fix: admin notices now visible for all operations (sync, create, update, delete, errors) +* Infrastructure: full dev tooling added (PHPCS, PHPStan, PHPUnit, deploy script) +* Real PHP minimum corrected to 8.0 (confirmed by PHPCompatibility 10.x scan) + +**Security** + +* CommonMark: disabled raw HTML passthrough (`html_input: strip`, `allow_unsafe_links: false`) — prevents stored XSS if an upstream repository is compromised +* Settings option now stored with `autoload = false` — encrypted GitHub token no longer loaded on every WordPress request (defense in depth) +* Replaced `serialize()` with `wp_json_encode()` in updater HMAC cache (eliminates PHPCS object-injection warning) +* Added strict base64 length validation before IV extraction in `robotstxt_docmd_decrypt_token()` +* Added `base64_decode()` strict return-value check in `robotstxt_docmd_get_file_content()` + +**Fixed** + +* Admin notices now display results for all operations: mapping created, mapping updated, mapping deleted, sync complete, and error messages — previously only "Settings saved" was shown +* `wp_update_post()` return value now checked during sync — silent post-update failures were previously reported as success +* Wrong textdomain `'robotstxt-smtp'` in updater class corrected to `'robotstxt-documentation-markdown'` + +**Changed** + +* `robotstxt-updater.php` renamed to `class-robotstxt-updater.php` (WordPress file-naming convention) +* Minimum PHP version header corrected from 8.2 to 8.0 (real minimum confirmed by PHPCompatibility 10.0.0-alpha2 scan — union types, `str_starts_with()`, `str_contains()`, and `mixed` type are the binding constraints) + +**Developer** + +* Added `composer.json` with full require-dev tooling (PHPCS, PHPStan 2.x, PHPUnit 9.6, PHPCompatibility 10.0.0-alpha2, wp-compat, yoast/phpunit-polyfills) +* Added `phpstan.neon` (level 9), `.phpcs.xml` (WordPress-Core/Docs/Extra), `bin/deploy.sh`, `phpunit.xml.dist` +* PHPUnit: 17 plugin header tests added (`tests/PluginHeadersTest.php`) +* Added `docs/known-issues.md` and `docs/db-migrations.md` +* `class-robotstxt-updater.php`: PHPCS 0 errors, PHPStan level 9 0 errors (full compliance) + +**Compatibility** + +* WordPress: 6.7 - 7.1 +* PHP: 8.0 - 8.5 +* MariaDB: 11.4 or newer + +**Tests** + +* PHP Coding Standards: PHPCS 3.x with WordPress-Core, WordPress-Docs, WordPress-Extra — 0 errors, 0 warnings +* WordPress Coding Standards: WPCS 3.3.0 +* PHPStan: level 9, 0 errors (szepeviktor/phpstan-wordpress 2.0.3) +* PHPCompatibility: 10.0.0-alpha2 — PHP 8.0-8.5 validated +* PHPUnit: 17/17 tests pass (plugin header tests) +* Manual testing: WordPress 7.0, 7.1 + = 1.1.0 = _Release date: 2026-03-28_ diff --git a/class-robotstxt-updater.php b/class-robotstxt-updater.php new file mode 100644 index 0000000..7b56a73 --- /dev/null +++ b/class-robotstxt-updater.php @@ -0,0 +1,465 @@ + + */ + private array $plugin_data; + + /** + * Initialize the updater. + * + * Usage in your main plugin file: + * require_once __DIR__ . '/class-robotstxt-updater.php'; + * Robotstxt_Updater::init( __FILE__ ); + * + * @since 1.0.0 + * + * @param string $plugin_file_path Absolute path to the main plugin file. + * @return void + */ + public static function init( string $plugin_file_path ): void { + $instance = new self( $plugin_file_path ); + $instance->register(); + } + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param string $plugin_file_path Absolute path to the main plugin file. + */ + private function __construct( string $plugin_file_path ) { + $this->plugin_file_path = $plugin_file_path; + $this->plugin_basename = plugin_basename( $plugin_file_path ); + $this->plugin_slug = dirname( $this->plugin_basename ); + $this->plugin_data = $this->get_plugin_data(); + $this->json_url = $this->build_json_url(); + $this->cache_key = 'robotstxt_updater_' . md5( $this->plugin_basename ); + } + + /** + * Register WordPress hooks. + * + * @since 1.0.0 + * + * @return void + */ + private function register(): void { + add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) ); + add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 ); + add_action( 'admin_init', array( $this, 'handle_cache_clear' ) ); + add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) ); + } + + /** + * Get plugin headers. + * + * @since 1.0.0 + * + * @return array Plugin data. + */ + private function get_plugin_data(): array { + if ( ! function_exists( 'get_plugin_data' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + return get_plugin_data( $this->plugin_file_path, false, false ); + } + + /** + * Build JSON URL from plugin headers. + * + * Tries to use "Gitea Plugin URI" header to construct the URL. + * Falls back to Plugin URI if Gitea URI is not available. + * + * @since 1.0.0 + * + * @return string JSON URL. + */ + private function build_json_url(): string { + $gitea_uri = isset( $this->plugin_data['Gitea Plugin URI'] ) && is_string( $this->plugin_data['Gitea Plugin URI'] ) + ? $this->plugin_data['Gitea Plugin URI'] + : ''; + + if ( '' !== $gitea_uri ) { + if ( str_starts_with( $gitea_uri, 'http' ) ) { + return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json'; + } + + if ( preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) { + return 'https://git.robotstxt.es/' . $gitea_uri . '/raw/branch/main/update.json'; + } + } + + $plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) + ? $this->plugin_data['PluginURI'] + : ''; + + if ( '' !== $plugin_uri && str_contains( $plugin_uri, 'git.robotstxt.es' ) ) { + return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json'; + } + + return 'https://git.robotstxt.es/ROBOTSTXT/' . $this->plugin_slug . '/raw/branch/main/update.json'; + } + + /** + * Inject update info into WP's plugin update transient. + * + * @since 1.0.0 + * + * @param mixed $transient The update_plugins transient value. + * @return mixed The modified transient. + */ + public function inject_update_info( $transient ) { + if ( ! ( $transient instanceof stdClass ) ) { + return $transient; + } + + if ( ! isset( $transient->checked ) || ! is_array( $transient->checked ) ) { + return $transient; + } + + if ( ! isset( $transient->checked[ $this->plugin_basename ] ) + || ! is_string( $transient->checked[ $this->plugin_basename ] ) + ) { + return $transient; + } + + $current_version = $transient->checked[ $this->plugin_basename ]; + $remote = $this->get_remote_data(); + + $remote_version = isset( $remote['version'] ) && is_string( $remote['version'] ) ? $remote['version'] : ''; + $download_url = isset( $remote['download_url'] ) && is_string( $remote['download_url'] ) ? $remote['download_url'] : ''; + + if ( '' === $remote_version || '' === $download_url ) { + return $transient; + } + + if ( ! $this->is_compatible( $remote ) ) { + return $transient; + } + + if ( version_compare( $remote_version, $current_version, '>' ) ) { + $plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) + ? $this->plugin_data['PluginURI'] + : ''; + + $update = (object) array( + 'slug' => isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug, + 'plugin' => $this->plugin_basename, + 'new_version' => $remote_version, + 'url' => isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : $plugin_uri, + 'package' => $download_url, + 'tested' => isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : '', + 'requires' => isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : '', + 'requires_php' => isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : '', + ); + + if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) { + $transient->response = array(); + } + + $transient->response[ $this->plugin_basename ] = $update; + } + + return $transient; + } + + /** + * Provide "View details" modal content. + * + * @since 1.0.0 + * + * @param false|object $result The result object or false if no result yet. + * @param string $action The type of information being requested. + * @param object $args Plugin API arguments. + * @return false|object The plugin information object or false. + */ + public function provide_plugin_details( $result, string $action, object $args ) { + if ( 'plugin_information' !== $action ) { + return $result; + } + + if ( ! isset( $args->slug ) || ! is_string( $args->slug ) || $args->slug !== $this->plugin_slug ) { + return $result; + } + + $remote = $this->get_remote_data(); + $remote_version = isset( $remote['version'] ) && is_string( $remote['version'] ) ? $remote['version'] : ''; + + if ( '' === $remote_version ) { + return $result; + } + + $plugin_name = isset( $this->plugin_data['Name'] ) && is_string( $this->plugin_data['Name'] ) + ? $this->plugin_data['Name'] + : $this->plugin_slug; + $plugin_uri = isset( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) + ? $this->plugin_data['PluginURI'] + : ''; + $description = isset( $this->plugin_data['Description'] ) && is_string( $this->plugin_data['Description'] ) + ? $this->plugin_data['Description'] + : ''; + $author = isset( $this->plugin_data['Author'] ) && is_string( $this->plugin_data['Author'] ) + ? $this->plugin_data['Author'] + : ''; + + return (object) array( + 'name' => isset( $remote['name'] ) && is_string( $remote['name'] ) ? $remote['name'] : $plugin_name, + 'slug' => isset( $remote['slug'] ) && is_string( $remote['slug'] ) ? $remote['slug'] : $this->plugin_slug, + 'version' => $remote_version, + 'author' => isset( $remote['author'] ) && is_string( $remote['author'] ) ? $remote['author'] : $author, + 'homepage' => isset( $remote['homepage'] ) && is_string( $remote['homepage'] ) ? $remote['homepage'] : $plugin_uri, + 'requires' => isset( $remote['requires'] ) && is_string( $remote['requires'] ) ? $remote['requires'] : '', + 'tested' => isset( $remote['tested'] ) && is_string( $remote['tested'] ) ? $remote['tested'] : '', + 'requires_php' => isset( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ? $remote['requires_php'] : '', + 'sections' => array( + 'description' => isset( $remote['description'] ) && is_string( $remote['description'] ) ? $remote['description'] : $description, + 'changelog' => isset( $remote['changelog'] ) && is_string( $remote['changelog'] ) ? $remote['changelog'] : '', + ), + 'download_link' => isset( $remote['download_url'] ) && is_string( $remote['download_url'] ) ? $remote['download_url'] : '', + ); + } + + /** + * Get remote data with caching and HMAC signature verification. + * + * @since 1.0.0 + * + * @return array Remote data. + */ + private function get_remote_data(): array { + $cached = get_site_transient( $this->cache_key ); + + if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { + if ( is_array( $cached ) + && isset( $cached['signature'], $cached['data'] ) + && is_string( $cached['signature'] ) + && is_array( $cached['data'] ) + ) { + $expected_sig = hash_hmac( + 'sha256', + $this->cache_key . wp_json_encode( $cached['data'] ), + AUTH_SALT + ); + + if ( hash_equals( $expected_sig, $cached['signature'] ) ) { + return $this->normalize_array( $cached['data'] ); + } + + delete_site_transient( $this->cache_key ); + $cached = false; + } + } + + if ( false === $cached ) { + $remote = $this->fetch_json(); + + if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) { + $payload = array( + 'data' => $remote, + 'timestamp' => time(), + 'signature' => hash_hmac( + 'sha256', + $this->cache_key . wp_json_encode( $remote ), + AUTH_SALT + ), + ); + set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS ); + } else { + set_site_transient( $this->cache_key, $remote, 6 * HOUR_IN_SECONDS ); + } + + return $remote; + } + + return is_array( $cached ) ? $this->normalize_array( $cached ) : array(); + } + + /** + * Fetch JSON from remote URL. + * + * @since 1.0.0 + * + * @return array Decoded JSON data. + */ + private function fetch_json(): array { + $response = wp_remote_get( + $this->json_url, + array( + 'timeout' => 10, + 'headers' => array( + 'Accept' => 'application/json', + ), + ) + ); + + if ( is_wp_error( $response ) ) { + return array(); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + if ( $code < 200 || $code >= 300 ) { + return array(); + } + + $body = wp_remote_retrieve_body( $response ); + $data = json_decode( $body, true ); + + return is_array( $data ) ? $data : array(); + } + + /** + * Check compatibility with current environment. + * + * @since 1.0.0 + * + * @param array $remote Remote data. + * @return bool True if compatible. + */ + private function is_compatible( array $remote ): bool { + if ( ! empty( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ) { + if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) { + return false; + } + } + + if ( ! empty( $remote['requires'] ) && is_string( $remote['requires'] ) ) { + if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) { + return false; + } + } + + return true; + } + + /** + * Handle manual cache clear via URL parameter. + * + * @since 1.0.0 + * + * @return void + */ + public function handle_cache_clear(): void { + $clear_cache = filter_input( INPUT_GET, 'robotstxt_clear_update_cache', FILTER_UNSAFE_RAW ); + if ( null === $clear_cache ) { + return; + } + + $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_UNSAFE_RAW ); + $nonce = ( null !== $nonce_raw && is_string( $nonce_raw ) ) + ? sanitize_text_field( wp_unslash( $nonce_raw ) ) + : ''; + + if ( ! wp_verify_nonce( $nonce, 'robotstxt_clear_update_cache' ) ) { + wp_die( esc_html__( 'Security check failed.', 'robotstxt-documentation-markdown' ) ); + } + + if ( ! current_user_can( 'update_plugins' ) ) { + wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-documentation-markdown' ) ); + } + + $this->clear_cache(); + wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) ); + exit; + } + + /** + * Rebuild an array guaranteeing string keys for PHPStan type safety. + * + * @since 1.0.0 + * + * @param array $data Raw array from transient or API. + * @return array + */ + private function normalize_array( array $data ): array { + $result = array(); + foreach ( $data as $k => $v ) { + if ( is_string( $k ) ) { + $result[ $k ] = $v; + } + } + return $result; + } + + /** + * Clear update cache. + * + * @since 1.0.0 + * + * @return void + */ + public function clear_cache(): void { + delete_site_transient( $this->cache_key ); + delete_site_transient( 'update_plugins' ); + } + } +} diff --git a/docs/audit-pre-deploy-1.1.1.md b/docs/audit-pre-deploy-1.1.1.md new file mode 100644 index 0000000..e29324f --- /dev/null +++ b/docs/audit-pre-deploy-1.1.1.md @@ -0,0 +1,90 @@ +# Pre-Deploy AI Security Audit — v1.1.1 + +**Date:** 2026-06-08 +**Plugin:** Documentation Markdown (by ROBOTSTXT) +**Version:** 1.1.0 → 1.1.1 +**Auditor:** Claude Sonnet (Senior WordPress Plugin Security Auditor role) +**Scope:** Full codebase (all 7 PHP plugin files) + +--- + +## Executive Summary + +**Overall status: CONDITIONAL PASS → PASS (after fixes applied)** + +All [CRITICAL] findings were resolved before tagging. No SQL injection, no CSRF gaps, no direct object reference, no privilege escalation vectors found. Access is gated behind `edit_pages` throughout with both UI-level and execution-level checks. + +**Security risk: Low** (after fixes) +**Version recommendation: stable** + +--- + +## Findings Resolved Before Release + +### [CRITICAL → FIXED] Finding 011 — CommonMark raw HTML passthrough + +**File:** `robotstxt-documentation-markdown-map.php:296` +**Problem:** CommonMark's default config allows raw HTML blocks from Markdown. A compromised upstream GitHub repository could inject `