diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 864a4f7..00ed899 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -60,7 +60,7 @@ body: id: versions attributes: label: Versions - placeholder: 'Procore Connect 2.0.0, WordPress 6.9, PHP 8.2' + placeholder: 'Procore Connect 2.0.3, WordPress 7.0, PHP 8.3' validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b6aba..d1ce21a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to Procore Connect are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.4] — 2026-08-08 + +### Security + +- `SECURITY.md` no longer recommends a version that destroys credentials. It listed all + of 2.0.x as supported and told readers to "upgrade to 2.0.0" — but 2.0.0 and 2.0.1 are + precisely the versions that over-encrypt the stored client secret until it is + unreadable. Both are now marked unsupported, with the fault, its blast radius and the + remedy stated plainly. The 1.x plaintext-storage warning is separated out and keeps its + instruction to rotate credentials. + +### Added + +- Tests for `Support\Logger`: redaction of secrets from log context, including nested + arrays, plus truncation and the bounded ring buffer. This is what stands between a + debug session and a client secret in `debug.log`, and it had no coverage. +- Tests for `Frontend\Renderer`: the child-theme → parent-theme → plugin override chain, + that an override actually renders and receives its data, directory-traversal refusal, + and notice escaping. Copying a template into a theme is a documented headline feature + and it had no coverage. + +### Changed + +- Refreshed the version placeholder in the bug-report template. + ## [2.0.3] — 2026-08-08 ### Fixed @@ -206,6 +231,7 @@ every install was non-functional regardless of configuration. - Initial release. +[2.0.4]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.4 [2.0.3]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.3 [2.0.2]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.2 [2.0.1]: https://github.com/ibuilder/ProcoreWP/releases/tag/v2.0.1 diff --git a/SECURITY.md b/SECURITY.md index 806361b..21cde11 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,16 +2,36 @@ ## Supported versions -| Version | Supported | -|---------|-----------| -| 2.0.x | ✅ | -| 1.x | ❌ — see the note below | - -Version 1.x is not supported and should not be used. It stored the Procore client secret -and access token as plaintext in an autoloaded WordPress option, ran its connection test -from an unverified `$_POST` key, and registered its settings with no sanitize callback. -It also could not authenticate against the live API. Upgrade to 2.0.0 and rotate any -credentials that were stored by 1.x, on the assumption they were exposed. +| Version | Supported | Notes | +|---------|-----------|-------| +| 2.0.3 | ✅ | Current | +| 2.0.2 | ⚠️ | Safe, but does not repair a credential already damaged by 2.0.0 or 2.0.1 | +| 2.0.0 – 2.0.1 | ❌ | **Destroy the stored client secret.** See below | +| 1.x | ❌ | Stores credentials in plaintext. See below | + +### 2.0.0 and 2.0.1 — stored credential destroyed + +These versions attached the settings sanitizer to `sanitize_option_{$option}` and +encrypted the client secret unconditionally. WordPress runs that filter on *every* +`update_option()` for the option, including the plugin's own internal writes, so the +stored secret gained an encryption layer on each write until it could no longer be +decrypted. The site then failed to authenticate with nothing on screen to explain why. + +This is a data-integrity fault rather than a disclosure: the value was over-encrypted, +never exposed. Upgrade to **2.0.3**, which unwraps and restores the value automatically +and reports what it did. If it cannot be recovered the secret is cleared and you are +asked to re-enter it. + +### 1.x — credentials stored in plaintext + +Version 1.x stored the Procore client secret, access token and refresh token as plaintext +in an **autoloaded** WordPress option, read into memory on every page request and present +in every database backup. It also ran its connection test from an unverified `$_POST` key +and registered its settings with no sanitize callback, and it could not authenticate +against the live API at all. + +Upgrade to 2.0.3 and **rotate any credentials 1.x stored**, on the assumption they were +exposed. ## Reporting a vulnerability diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0aed712..d2dfab8 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -81,8 +81,13 @@ /tests/wp-shims.php + - /tests/wp-shims.php + /tests/* /tests/wp-shims.php diff --git a/procore-connect.php b/procore-connect.php index 5de4d46..db5b2ed 100644 --- a/procore-connect.php +++ b/procore-connect.php @@ -3,7 +3,7 @@ * Plugin Name: Procore Connect * Plugin URI: https://github.com/ibuilder/ProcoreWP * Description: Connect WordPress to the Procore construction management platform. Display projects, teams, drawings, RFIs and more with shortcodes, blocks and a cached REST proxy. - * Version: 2.0.3 + * Version: 2.0.4 * Requires at least: 6.5 * Requires PHP: 7.4 * Author: ibuilder @@ -22,7 +22,7 @@ defined( 'ABSPATH' ) || exit; -const VERSION = '2.0.3'; +const VERSION = '2.0.4'; define( 'PROCORE_CONNECT_VERSION', VERSION ); define( 'PROCORE_CONNECT_FILE', __FILE__ ); diff --git a/readme.txt b/readme.txt index 6f3839d..22e633a 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: procore, construction, project management, shortcode, api Requires at least: 6.5 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 2.0.3 +Stable tag: 2.0.4 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -127,6 +127,11 @@ Yes. Caching goes through the transient API, so a persistent object cache such a == Changelog == += 2.0.4 = + +* Security documentation no longer recommends 2.0.0 or 2.0.1, the versions that damage the stored Client Secret. Both are now marked unsupported with the remedy stated plainly. +* Added test coverage for log redaction, which keeps secrets out of debug.log, and for the theme template override chain. + = 2.0.3 = * **Repairs credentials damaged by 2.0.0 or 2.0.1.** Those versions re-encrypted the stored Client Secret on every settings save until it could no longer be read, leaving the site unable to authenticate with no explanation. 2.0.2 stopped the damage; 2.0.3 undoes it. The upgrade unwraps the value, re-stores it correctly, and tells you what it did. If it cannot be recovered it is cleared and you are asked to enter it again. @@ -178,6 +183,9 @@ A complete rewrite. See the upgrade notice below before updating. == Upgrade Notice == += 2.0.4 = +Documentation and test coverage only. No functional change from 2.0.3. + = 2.0.3 = Repairs a Client Secret damaged by 2.0.0 or 2.0.1, which re-encrypted it on every save until it stopped working. Upgrading restores it automatically and reports the result. Recommended for anyone who ran 2.0.0 or 2.0.1. diff --git a/tests/unit/LoggerTest.php b/tests/unit/LoggerTest.php new file mode 100644 index 0000000..030d58b --- /dev/null +++ b/tests/unit/LoggerTest.php @@ -0,0 +1,152 @@ +assertSame( array(), Logger::entries() ); + } + + /** + * An enabled logger records the message and its level. + * + * @return void + */ + public function test_records_message_and_level(): void { + Logger::warning( 'throttled' ); + + $entries = Logger::entries(); + + $this->assertCount( 1, $entries ); + $this->assertSame( 'warning', $entries[0]['level'] ); + $this->assertSame( 'throttled', $entries[0]['message'] ); + } + + /** + * Credentials passed as context must never be written. + * + * @return void + */ + public function test_redacts_credentials_from_context(): void { + Logger::error( + 'auth failed', + array( + 'client_secret' => 'sk_live_should_never_appear', + 'access_token' => 'tok_should_never_appear', + 'refresh_token' => 'ref_should_never_appear', + 'code' => 'authcode_should_never_appear', + 'password' => 'pw_should_never_appear', + 'status' => 401, + ) + ); + + $dump = (string) wp_json_encode( Logger::entries() ); + + foreach ( array( 'sk_live_should_never_appear', 'tok_should_never_appear', 'ref_should_never_appear', 'authcode_should_never_appear', 'pw_should_never_appear' ) as $secret ) { + $this->assertStringNotContainsString( $secret, $dump ); + } + + // Non-sensitive context is still useful and must survive. + $this->assertStringContainsString( '401', $dump ); + } + + /** + * Redaction must reach credentials nested inside arrays. + * + * @return void + */ + public function test_redacts_nested_credentials(): void { + Logger::error( + 'request failed', + array( + 'request' => array( + 'headers' => array( 'Authorization' => 'Bearer leaked_token_value' ), + 'body' => array( 'client_secret' => 'nested_secret_value' ), + ), + ) + ); + + $dump = (string) wp_json_encode( Logger::entries() ); + + $this->assertStringNotContainsString( 'leaked_token_value', $dump ); + $this->assertStringNotContainsString( 'nested_secret_value', $dump ); + $this->assertStringContainsString( '[redacted]', $dump ); + } + + /** + * Long context values are truncated so the log cannot balloon. + * + * @return void + */ + public function test_truncates_very_long_context_values(): void { + Logger::info( 'big', array( 'body' => str_repeat( 'x', 5000 ) ) ); + + $entries = Logger::entries(); + + $this->assertLessThanOrEqual( 500, strlen( (string) $entries[0]['context']['body'] ) ); + } + + /** + * The ring buffer must stay bounded, newest first. + * + * @return void + */ + public function test_ring_buffer_is_bounded_and_newest_first(): void { + for ( $i = 0; $i < 60; $i++ ) { + Logger::info( 'entry ' . $i ); + } + + $entries = Logger::entries(); + + $this->assertCount( 50, $entries ); + $this->assertSame( 'entry 59', $entries[0]['message'] ); + } + + /** + * Clearing must empty the buffer. + * + * @return void + */ + public function test_clear_empties_the_buffer(): void { + Logger::info( 'something' ); + Logger::clear(); + + $this->assertSame( array(), Logger::entries() ); + } +} diff --git a/tests/unit/RendererTest.php b/tests/unit/RendererTest.php new file mode 100644 index 0000000..1d18292 --- /dev/null +++ b/tests/unit/RendererTest.php @@ -0,0 +1,178 @@ +child = get_stylesheet_directory() . '/procore-connect'; + $this->parent = get_template_directory() . '/procore-connect'; + + foreach ( array( $this->child, $this->parent ) as $dir ) { + if ( ! is_dir( $dir ) ) { + mkdir( $dir, 0777, true ); + } + } + + $this->remove_overrides(); + } + + /** + * Remove anything a test wrote into the theme directories. + * + * @return void + */ + protected function tearDown(): void { + $this->remove_overrides(); + + parent::tearDown(); + } + + /** + * Delete the override files used by these tests. + * + * @return void + */ + private function remove_overrides(): void { + foreach ( array( $this->child, $this->parent ) as $dir ) { + $files = glob( $dir . '/*.php' ); + + foreach ( is_array( $files ) ? $files : array() as $file ) { + unlink( $file ); + } + } + } + + /** + * With no override present, the plugin's own template is used. + * + * @return void + */ + public function test_falls_back_to_the_plugin_template(): void { + $located = Renderer::locate( 'collection' ); + + $this->assertStringContainsString( 'templates/collection.php', str_replace( '\\', '/', $located ) ); + $this->assertFileExists( $located ); + } + + /** + * A parent theme override beats the plugin default. + * + * @return void + */ + public function test_parent_theme_overrides_the_plugin(): void { + file_put_contents( $this->parent . '/collection.php', 'assertSame( + str_replace( '\\', '/', $this->parent . '/collection.php' ), + str_replace( '\\', '/', Renderer::locate( 'collection' ) ) + ); + } + + /** + * A child theme override beats the parent theme. + * + * @return void + */ + public function test_child_theme_wins_over_parent(): void { + file_put_contents( $this->parent . '/collection.php', 'child . '/collection.php', 'assertSame( + str_replace( '\\', '/', $this->child . '/collection.php' ), + str_replace( '\\', '/', Renderer::locate( 'collection' ) ) + ); + } + + /** + * An override actually renders, and receives the template data. + * + * @return void + */ + public function test_override_renders_and_receives_data(): void { + file_put_contents( $this->child . '/collection.php', 'assertSame( 'OVERRIDE:Projects', Renderer::render( 'collection', array( 'title' => 'Projects' ) ) ); + } + + /** + * A directory traversal attempt must not escape the template directories. + * + * @return void + */ + public function test_refuses_directory_traversal(): void { + foreach ( array( '../../wp-config', '..\\..\\wp-config', 'collection/../../../secret' ) as $attempt ) { + $located = Renderer::locate( $attempt ); + + $this->assertStringNotContainsString( 'wp-config', $located ); + $this->assertStringNotContainsString( 'secret', $located ); + } + } + + /** + * An unknown template resolves to nothing rather than erroring. + * + * @return void + */ + public function test_unknown_template_resolves_to_nothing(): void { + $this->assertSame( '', Renderer::locate( 'does-not-exist' ) ); + $this->assertSame( '', Renderer::render( 'does-not-exist', array() ) ); + } + + /** + * An empty name must not resolve to a directory. + * + * @return void + */ + public function test_empty_template_name_resolves_to_nothing(): void { + $this->assertSame( '', Renderer::locate( '' ) ); + } + + /** + * Public notices must be escaped and carry a sanitized modifier class. + * + * @return void + */ + public function test_notice_escapes_its_message(): void { + $html = Renderer::notice( '', 'error' ); + + $this->assertStringNotContainsString( '