Repository navigation
04 Checks
Each check answers one question about the submission and returns a FormSpamChecker\Data\CheckOutcome.
The pipeline runs the checks in a fixed order, sums their scores and compares the sum with two
thresholds; a hard verdict short-circuits that arithmetic.
CheckOutcome(
string $check, // stable snake_case name, used in logs
CheckStatus $status, // spam | ham | uncertain | error
int $score = 0, // 0..100, capped sum decides Suspicious/Spam
string $reason = '', // human-readable, appears in Verdict::reasons()
array $evidence = [], // structured proof for the log
)Quote: docs/CHECKS.md, opening section.
| Status | Meaning | Score |
|---|---|---|
spam |
hard verdict, ignores the numeric total | stored as evidence |
ham |
no hard verdict; a positive score is a suspicion signal | added to the total |
uncertain |
not enough data — this means "not checked", not "clean" | contributes 0 |
error |
the check failed; the pipeline fails open | contributes 0 |
The pipeline sums scores, caps the sum at 100 and compares it with thresholds.spam (default 60)
and thresholds.suspicious (default 30). Any spam outcome makes the whole submission spam. The
design rule behind the split: hard verdicts are reserved for evidence that cannot occur in a
legitimate message, everything debatable is point-based (docs/CHECKS.md, Check semantics).
SpamChecker::defaultChecks() registers, in order (docs/CHECKS.md, Pipeline order):
1 honeypot 7 links 13 js_challenge (behavioural)
2 time_trap 8 keywords 14 abuseipdb (network)
3 user_agent 9 email 15 dnsbl (network)
4 rate_limit 10 repetition 16 spf (network)
5 length 11 language 17 dmarc (network)
6 sql_injection 12 dynamic_honeypot 18 bayes
Cheap local checks run first, and pipeline.stop_on_spam (default true) stops the run on the
first hard spam, which saves AbuseIPDB quota and DNS lookups. The 2.1 autoban check runs
before all of them: a banned IP is rejected before any other check runs (README.md, Fail2Ban
and auto-ban). A check that throws is recorded as error and the pipeline continues.
All scores are 0–100; "spam" means a hard verdict. The table reproduces README.md, section Check
modules and defaults, where the reference deployments may use a different value (that is marked).
| Layer | Check | Default | Score or effect |
|---|---|---|---|
| Behaviour | honeypot |
on | filled hidden field → spam 100 |
| Behaviour | dynamic_honeypot |
on when a ≥16-character secret is set | filled → spam 100; neither expected field posted → 10 |
| Behaviour | time_trap |
off (needs a rendered secret) | too fast or forged → spam 80; missing/expired → 30 |
| Behaviour | js_challenge |
off | missing or invalid answer → 15, never hard |
| Identity | user_agent |
on | empty UA → 40; tool/marker UA → spam 100 |
| Volume | rate_limit |
on, 8 requests / 900 s | exceeded → spam 100 |
| Firewall | autoban |
off (reference deployments: on) | banned IP → spam 100, before any other check |
| Payload | length |
on, max 5000 | too long → 30; above 20000 or control characters → spam 100 |
| Injection | sql_injection |
on | strong signature → spam 100; weak → 40 |
| Injection | xss |
on | script/handler → spam 100; encoded → 45 |
| Content | links |
on, max 2 | extra link 15; shortener 35; obfuscated 25 |
| Content | keywords |
on | 12 points per hit, max 90 |
| Contact | email |
on | invalid 40; disposable 70 |
| Content | repetition |
on | runs and repeats → 25, max 60 |
| Content | language |
off | mixed scripts → 20, max 40 |
| Network | abuseipdb |
off | confidence at or above the threshold → spam (score = confidence) |
| Network | dnsbl |
off | listing → spam 100 |
| Domain | spf |
off | no usable policy → 10 |
| Domain | dmarc |
off | missing → 10; p=none → 5 |
| Model | bayes |
off |
P(spam) at or above 0.92 → score = P × 100, suspicious by default |
dkim is configured but not implemented in this release: the section is accepted and ignored, and
there is no DKIM check in defaultChecks() (README.md, Check modules and defaults;
docs/CHECKS.md, dkim).
| Check | Signal | Chief false positive |
|---|---|---|
honeypot |
any value in a CSS-hidden field | password managers occasionally fill hidden fields |
dynamic_honeypot |
value in an hourly HMAC-derived field; the current and previous hour are accepted | clients that strip hidden inputs before posting |
time_trap |
age of a signed token against min_seconds (2) and max_seconds (604800) |
pages cached for longer than max_seconds
|
user_agent |
case-insensitive markers for HTTP libraries, headless browsers, scanners | a legitimate curl integration |
rate_limit |
per-IP counter in durable storage | shared corporate NAT and legitimate retries |
length |
field longer than 5000, 20000, or NUL/control characters | genuinely long messages |
sql_injection |
strong signatures hard, weak fragments 40 | an engineer describing a query |
xss |
script tags, handlers, javascript:, encoded payloads |
a user pasting HTML e-mail content |
links |
more than 2 links, shortener hosts, hxxp:// spellings |
support requests quoting several URLs |
keywords |
multibyte substring scoring plus optional PCRE | a B2B message mentioning "credit" or "seo" |
email |
syntax, disposable-domain suffix match | a legitimate privacy-mail user |
repetition |
character runs and repeated words | emphasis in ordinary prose |
language |
Cyrillic and Latin letters inside one word; with require_cyrillic (opt-in, off by default) a Latin-only name field scores missing_score (40; 60 makes it hard spam) |
brand names and transliterations |
Semantics and tuning for each row are in docs/CHECKS.md, section Local checks; the scores above
come from docs/CONFIG.md, Full default tree.
Network checks are registered last, only when enabled is true, never run without the needed data
(no IP, private IP, no API key, no model), and all fail open (docs/CHECKS.md, Network checks).
| Situation | Result |
|---|---|
| disabled, no API key, or no valid public IP |
uncertain, no network call |
| cached answer for this IP | cached outcome (default 6 h) |
score at or above threshold
|
hard spam, score = confidence |
score below threshold
|
ham, 0, confidence in evidence |
| HTTP 429 |
uncertain; Retry-After stored and later lookups back off |
| transport error or HTTP 5xx | breaker failure → uncertain (or error with fail_open = false) |
| 3 consecutive failures | circuit opens for 300 s, fast uncertain
|
| first successful HTTP response | circuit closes |
The library default abuseipdb.threshold is 90; the 2.1 reference deployments set 100, which
means only IPs whose confidence score is exactly 100 are treated as spam (README.md, AbuseIPDB
setup and Fail2Ban and auto-ban; CHANGELOG.md, [2.1.0] Added, Deployment guidance). Caching,
max_age_days (30) and the breaker limits are quoted from docs/CONFIG.md, Full default tree.
The client IP is sent to AbuseIPDB — the only form-related data that leaves the machine, and only
when the check is enabled (SECURITY.md, Network egress). Private, reserved and loopback addresses
are never sent (docs/CHECKS.md, abuseipdb).
-
dnsblreverses the client IP (IPv4 and IPv6 nibble form) and queries every configured zone (zen.spamhaus.org,bl.spamcop.netby default); an answer inside127.0.0.0/24is a listing and a hard spam withdnsbl.hit_score(100). A resolver that answers nothing for every zone yieldsham: a broken resolver must not punish a submission (docs/CHECKS.md, dnsbl). -
spfanddmarcask a domain-level question — does the domain claimed in the e-mail field publish a usable policy — not a sender question. A visitor's IP is not the sending MTA, sofailfor a legitimategmail.comaddress is normal; both checks only contribute a score and never produce a hard verdict (docs/CHECKS.md, spf and dmarc).
Off by default and abstains until the model has bayes.min_documents (20) documents. Above
bayes.threshold (0.92) the check scores round(probability × score_scale), a suspicion unless
bayes.hard is set. The model stores token counts only, never the training documents, and training
is offline (docs/CHECKS.md, bayes):
php bin/spam-train --config=/etc/spam.php --ham=/etc/spam/ham.txt --spam=/etc/spam/spam.txt
php bin/spam-train --config=/etc/spam.php --stats
php bin/spam-train --config=/etc/spam.php --resetThere is no label feedback loop from the pipeline: retrain from collected samples and keep
bayes.hard false until the model has been reviewed on real traffic (docs/INTEGRATION.md,
Training the Bayesian model).
A check implements FormSpamChecker\Check\SpamCheckInterface and returns CheckOutcome
(docs/CHECKS.md, Writing a custom check):
use FormSpamChecker\Check\SpamCheckInterface;
use FormSpamChecker\Data\CheckOutcome;
use FormSpamChecker\Data\RequestData;
final readonly class MyCheck implements SpamCheckInterface
{
public function name(): string
{
return 'my_check';
}
public function check(RequestData $data): CheckOutcome
{
if ($data->field('phone') === '') {
return CheckOutcome::suspicious('my_check', 20, 'phone is required', [
'field' => 'phone',
]);
}
return CheckOutcome::ham('my_check');
}
}Register it explicitly with the default pipeline, or append it immutably where you own the pipeline:
$checks = [...\FormSpamChecker\SpamChecker::defaultChecks($config, $storage), new MyCheck()];
$checker = new \FormSpamChecker\SpamChecker($config, $storage, $checks);A check that throws is caught and recorded as check error (fail open): <message>. A new check
belongs in Config::defaults() and needs a positive fixture and a false-positive decoy (AGENTS.md,
Rules).
The form checks judge a submission; the WAF rules judge a whole HTTP request and answer block or
allow. A WAF block happens before the form handler, so form checks never see that request — keep
the WAF in monitor first when adding it to a form that already has form checks
(docs/CHECKS.md, WAF rules vs form checks). See WAF.
- WAF — the request-level layer.
- Configuration — where these scores are changed.
repository · releases · MIT