diff --git a/README.md b/README.md index 02e4b16..1bcf7fc 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,34 @@ itself is enabled. block. Note: this sends the submitted email to a third-party service, so cover it in your privacy policy / DPA. +## Logging + +Turn on **Debug logging** (Forms → Settings → ALTCHA) to record every decision — +useful for verifying behaviour in staging or chasing a report. Each decision +writes one greppable line to the PHP error log: + +``` +[gravityforms-altcha] altcha: pass {"form":43} +[gravityforms-altcha] altcha: fail {"form":43,"reason":"replay"} +[gravityforms-altcha] rate_limit: blocked {"form":12} +[gravityforms-altcha] content_filter: spam {"form":7,"score":3,"signals":["keyword"]} +[gravityforms-altcha] email_validation: blocked {"form":7,"status":"undeliverable","disposable":false,"reason":"rejected_email","domain":"exmaple.com"} +``` + +`reason` for ALTCHA is `missing` / `invalid` / `replay`. **No PII is logged** — +IPs only ever appear as a salted hash, and emails as the domain only. + +Route the records elsewhere (Sentry, Query Monitor, …) via the +`genero/gravityforms_altcha/log` action — and optionally drop the default +error-log line: + +```php +remove_action('genero/gravityforms_altcha/log', ['Genero\GravityFormsAltcha\Logger', 'writeToErrorLog']); +add_action('genero/gravityforms_altcha/log', function (string $event, string $outcome, array $context) { + // ship $event/$outcome/$context to your sink +}, 10, 3); +``` + ## How it works 1. **Form render** — when enabled for the form, the plugin injects a hidden @@ -192,6 +220,14 @@ add_filter('genero/gravityforms_altcha/spam_keywords', fn (array $words) => [... add_filter('genero/gravityforms_altcha/spam_score_threshold', fn () => 4); ``` +### `genero/gravityforms_altcha/logging` + +Force decision logging on or off regardless of the *Debug logging* setting: + +```php +add_filter('genero/gravityforms_altcha/logging', fn () => defined('WP_DEBUG') && WP_DEBUG); +``` + ### `genero/gravityforms_altcha/bouncer_api_key` Provide the Bouncer key in code instead of the `BOUNCER_API_KEY` env var (e.g. diff --git a/gravityforms-altcha.php b/gravityforms-altcha.php index 81bfcb0..5f9b658 100644 --- a/gravityforms-altcha.php +++ b/gravityforms-altcha.php @@ -6,7 +6,7 @@ * Plugin Name: ALTCHA for Gravity Forms * Plugin URI: https://github.com/generoi/gravityforms-altcha * Description: Invisible ALTCHA spam protection for Gravity Forms — uses the MIT-licensed altcha-org/altcha PHP library and the ALTCHA widget web component to proof-of-work-verify every form submission with no user interaction. - * Version: 0.3.0 + * Version: 0.4.0 * Requires at least: 6.0 * Requires PHP: 8.2 * Author: Genero diff --git a/src/EmailValidator.php b/src/EmailValidator.php index 1dfbc9d..2fd254c 100644 --- a/src/EmailValidator.php +++ b/src/EmailValidator.php @@ -69,7 +69,19 @@ public function validateField($result, $value, $form, $field) return $result; } - if (! self::shouldBlock(self::validate($email), Settings::emailBlockModes())) { + $verdict = self::validate($email); + $block = self::shouldBlock($verdict, Settings::emailBlockModes()); + + Logger::record('email_validation', $block ? 'blocked' : 'allowed', [ + 'form' => $form['id'] ?? null, + 'status' => $verdict['status'], + 'disposable' => $verdict['disposable'], + 'reason' => $verdict['reason'], + // Domain only — never the full address. + 'domain' => self::emailDomain($email), + ]); + + if (! $block) { return $result; } @@ -118,6 +130,17 @@ public static function validate(string $email): array * * @param array{reason?: ?string} $result */ + /** + * The domain part of an email, for privacy-safe logging (never the local + * part). Returns '' when there's no '@'. + */ + public static function emailDomain(string $email): string + { + $at = strrpos($email, '@'); + + return $at === false ? '' : strtolower(substr($email, $at + 1)); + } + public static function isDefinitive(array $result): bool { $reason = (string) ($result['reason'] ?? ''); diff --git a/src/Integration.php b/src/Integration.php index 642bb33..5b7b765 100644 --- a/src/Integration.php +++ b/src/Integration.php @@ -59,14 +59,25 @@ public function validate(array $result): array return $result; } + $formId = $result['form']['id'] ?? null; + $payload = isset($_POST[self::POST_FIELD]) && is_string($_POST[self::POST_FIELD]) ? sanitize_text_field(wp_unslash($_POST[self::POST_FIELD])) : ''; - if ($this->challenge()->verify($payload) && ! $this->isReplay($payload)) { - return $result; + if ($this->challenge()->verify($payload)) { + if (! $this->isReplay($payload)) { + Logger::record('altcha', 'pass', ['form' => $formId]); + + return $result; + } + $reason = 'replay'; + } else { + $reason = $payload === '' ? 'missing' : 'invalid'; } + Logger::record('altcha', 'fail', ['form' => $formId, 'reason' => $reason]); + $result['is_valid'] = false; $result['form']['validation_summary_message'] = $this->errorMessage(); $result['form']['failed_validation_page'] = $result['form']['page_count'] ?? 1; diff --git a/src/Logger.php b/src/Logger.php new file mode 100644 index 0000000..c374f44 --- /dev/null +++ b/src/Logger.php @@ -0,0 +1,64 @@ + $context non-PII detail + */ + public static function record(string $event, string $outcome, array $context = []): void + { + if (! self::enabled()) { + return; + } + + do_action(self::HOOK, $event, $outcome, $context); + } + + public static function enabled(): bool + { + /** + * Filters whether decisions are logged. Defaults to the "Debug logging" + * setting (wired in Plugin); return true/false to override. + */ + return (bool) apply_filters('genero/gravityforms_altcha/logging', false); + } + + /** + * Default sink — a single greppable line per decision. Other listeners can + * be added on the same hook, or this one removed to fully take over routing. + * + * @param array $context + */ + public static function writeToErrorLog(string $event, string $outcome, array $context = []): void + { + error_log(self::format($event, $outcome, $context)); + } + + /** + * @param array $context + */ + public static function format(string $event, string $outcome, array $context = []): string + { + $suffix = $context !== [] ? ' '.json_encode($context) : ''; + + return sprintf('[gravityforms-altcha] %s: %s%s', $event, $outcome, $suffix); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 0ce14b1..10974bd 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,7 +4,7 @@ class Plugin { - public const VERSION = '0.3.0'; + public const VERSION = '0.4.0'; public const SLUG = 'gravityforms-altcha'; @@ -63,6 +63,10 @@ public function registerHooks(): void return; } + // Logging: drive the toggle from settings, and write the default sink. + add_filter('genero/gravityforms_altcha/logging', [Settings::class, 'loggingEnabled']); + add_action(Logger::HOOK, [Logger::class, 'writeToErrorLog'], 10, 3); + Integration::register(); ChallengeEndpoint::register(); SpamFilter::register(); diff --git a/src/Settings.php b/src/Settings.php index 971d6c1..7cba4d3 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -97,6 +97,13 @@ public function plugin_settings_fields() 'tooltip' => esc_html__('Asks the visitor to correct a bad email address before submitting. Verifies via Bouncer — requires the BOUNCER_API_KEY environment variable, and sends the email to a third-party service. Fails open (never blocks) if the service is unavailable. Applies to all Gravity Forms.', 'gravityforms-altcha'), 'default_value' => false, ], + [ + 'name' => 'enable_logging', + 'type' => 'toggle', + 'label' => esc_html__('Debug logging', 'gravityforms-altcha'), + 'tooltip' => esc_html__('Logs every ALTCHA, rate-limit, content-filter and email decision (pass or fail) to the PHP error log so you can verify behaviour. No raw IPs or full email addresses are logged. Leave off in normal operation.', 'gravityforms-altcha'), + 'default_value' => false, + ], [ 'name' => 'email_block', 'type' => 'checkbox', @@ -220,6 +227,11 @@ public static function emailValidationEnabled(): bool return (bool) self::get_instance()->get_plugin_setting('enable_email_validation'); } + public static function loggingEnabled(): bool + { + return (bool) self::get_instance()->get_plugin_setting('enable_logging'); + } + /** * Which Bouncer verdicts the admin has opted to reject. Unchecked / unset → * false, so a verdict is only ever acted on when explicitly enabled. diff --git a/src/SpamFilter.php b/src/SpamFilter.php index 8eac3a6..deb275b 100644 --- a/src/SpamFilter.php +++ b/src/SpamFilter.php @@ -42,15 +42,31 @@ public function flag($isSpam, $form, $entry): bool return (bool) $isSpam; } - if (Settings::rateLimitEnabled() && $this->exceedsRateLimit($form)) { - return true; + $formId = $form['id'] ?? null; + + if (Settings::rateLimitEnabled()) { + if ($this->exceedsRateLimit($form)) { + Logger::record('rate_limit', 'blocked', ['form' => $formId]); + + return true; + } + Logger::record('rate_limit', 'pass', ['form' => $formId]); } if (Settings::contentFilterEnabled()) { $text = $this->extractText($form, $entry); - if (self::contentIsSpam($text['body'], $text['identity'], self::keywords(), self::scoreThreshold())) { + $report = self::contentReport($text['body'], $text['identity'], self::keywords(), self::scoreThreshold()); + + if ($report['spam']) { + Logger::record('content_filter', 'spam', [ + 'form' => $formId, + 'score' => $report['score'], + 'signals' => $report['signals'], + ]); + return true; } + Logger::record('content_filter', 'pass', ['form' => $formId, 'score' => $report['score']]); } return (bool) $isSpam; @@ -213,48 +229,68 @@ private function fieldValue($field, array $entry): string * @param array $keywords */ public static function contentIsSpam(string $body, string $identity, array $keywords, int $threshold): bool + { + return self::contentReport($body, $identity, $keywords, $threshold)['spam']; + } + + /** + * Scores content and reports which signals fired — used both for the spam + * decision and for logging. A definite keyword flags on a single hit; + * otherwise weaker signals must accumulate to the threshold, so a lone link + * or foreign word never trips it. + * + * @param array $keywords + * @return array{spam: bool, score: int, signals: array} + */ + public static function contentReport(string $body, string $identity, array $keywords, int $threshold): array { $identity = self::normalize($identity); $body = self::normalize($body); $combined = trim($identity.' '.$body); if ($combined === '') { - return false; + return ['spam' => false, 'score' => 0, 'signals' => []]; } // Definite-spam keywords — matched on word boundaries (unicode-aware) so // a keyword can't trip on a substring of an innocent word. foreach ($keywords as $keyword) { if ($keyword !== '' && self::containsWord($combined, $keyword)) { - return true; + return ['spam' => true, 'score' => $threshold, 'signals' => ['keyword']]; } } $score = 0; + $signals = []; // Links (http(s):// and scheme-less www.), graduated: a single link is // innocent, a pile of them is not. $links = self::countLinks($combined); if ($links >= 5) { $score += 3; + $signals[] = 'many_links'; } elseif ($links >= 3) { $score += 2; + $signals[] = 'links'; } // A URL in the name field is near-certain spam — real names aren't links. if ($identity !== '' && self::countLinks($identity) >= 1) { $score += 3; + $signals[] = 'url_in_name'; } if (preg_match('~\[url=|= $threshold; + return ['spam' => $score >= $threshold, 'score' => $score, 'signals' => $signals]; } /** diff --git a/test/mocked/LoggerRecordTest.php b/test/mocked/LoggerRecordTest.php new file mode 100644 index 0000000..c0d1125 --- /dev/null +++ b/test/mocked/LoggerRecordTest.php @@ -0,0 +1,28 @@ +justReturn(false); + Actions\expectDone(Logger::HOOK)->never(); + + Logger::record('altcha', 'pass', ['form' => 1]); + } + + public function test_fires_the_log_action_when_enabled(): void + { + Functions\when('apply_filters')->justReturn(true); + Actions\expectDone(Logger::HOOK)->once()->with('rate_limit', 'blocked', ['form' => 1]); + + Logger::record('rate_limit', 'blocked', ['form' => 1]); + } +} diff --git a/test/mocked/MockedTestCase.php b/test/mocked/MockedTestCase.php index 1a5aa33..14d3b84 100644 --- a/test/mocked/MockedTestCase.php +++ b/test/mocked/MockedTestCase.php @@ -5,6 +5,7 @@ namespace Genero\GravityFormsAltcha\Tests\Mocked; use Brain\Monkey; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PHPUnit\Framework\TestCase; /** @@ -13,6 +14,9 @@ */ abstract class MockedTestCase extends TestCase { + // Counts Brain\Monkey/Mockery expectations as PHPUnit assertions. + use MockeryPHPUnitIntegration; + protected function setUp(): void { parent::setUp(); diff --git a/test/unit/EmailValidatorTest.php b/test/unit/EmailValidatorTest.php index d3c0af4..914b030 100644 --- a/test/unit/EmailValidatorTest.php +++ b/test/unit/EmailValidatorTest.php @@ -64,4 +64,10 @@ public function test_real_verdicts_are_definitive(): void $this->assertTrue(EmailValidator::isDefinitive(EmailValidator::result('deliverable'))); $this->assertTrue(EmailValidator::isDefinitive(EmailValidator::result('undeliverable', reason: 'rejected_email'))); } + + public function test_email_domain_extracted_for_logging(): void + { + $this->assertSame('gmail.com', EmailValidator::emailDomain('Foo.Bar@Gmail.com')); + $this->assertSame('', EmailValidator::emailDomain('no-at-sign')); + } } diff --git a/test/unit/LoggerTest.php b/test/unit/LoggerTest.php new file mode 100644 index 0000000..35634c3 --- /dev/null +++ b/test/unit/LoggerTest.php @@ -0,0 +1,24 @@ +assertSame('[gravityforms-altcha] altcha: pass', Logger::format('altcha', 'pass')); + } + + public function test_format_with_context(): void + { + $this->assertSame( + '[gravityforms-altcha] altcha: fail {"form":43,"reason":"invalid"}', + Logger::format('altcha', 'fail', ['form' => 43, 'reason' => 'invalid']), + ); + } +} diff --git a/test/unit/SpamFilterTest.php b/test/unit/SpamFilterTest.php index 3b4d2a6..3e37265 100644 --- a/test/unit/SpamFilterTest.php +++ b/test/unit/SpamFilterTest.php @@ -91,6 +91,21 @@ public function test_keywords_are_case_insensitive(): void $this->assertTrue($this->isSpam('ViAgRa')); } + public function test_content_report_exposes_signals_for_logging(): void + { + $keyword = SpamFilter::contentReport('buy viagra', '', self::KEYWORDS, self::THRESHOLD); + $this->assertTrue($keyword['spam']); + $this->assertSame(['keyword'], $keyword['signals']); + + $combined = SpamFilter::contentReport('Спасибо http://a.com http://b.com http://c.com', '', self::KEYWORDS, self::THRESHOLD); + $this->assertTrue($combined['spam']); + $this->assertContains('links', $combined['signals']); + $this->assertContains('wrong_script', $combined['signals']); + + $name = SpamFilter::contentReport('hello', 'http://spam.example', self::KEYWORDS, self::THRESHOLD); + $this->assertContains('url_in_name', $name['signals']); + } + public function test_resolve_ip_uses_remote_addr_by_default(): void { $this->assertSame('203.0.113.9', SpamFilter::resolveIp(['REMOTE_ADDR' => '203.0.113.9'], ['REMOTE_ADDR']));