Repository navigation
Releases: Guard-Core/guard-core-php
Release list
v4.4.0
v4.4.0 (2026-10-15)
Scan budget, client IP and IP list parity, dynamic rules over the base config, and a response modifier seam (v4.4.0)
A release in lockstep with the core engines at 4.4.0. It adds the per-request scan time budget, brings the client identity and the IP list checks to the reference behavior, applies dynamic rules over the base config, and adds GuardEngine::applyResponseModifier() for adapters.
Added
detectionMaxScanSeconds: a per-request cap on regex scan CPU time (default10.0, range0.1to60.0, PR #80). Each pattern scan's CPU time (getrusage()user plus system time, so time spent descheduled is not charged) is summed per request and checked between patterns. Once it is spent the remaining patterns and values are skipped and the request records a weightedpattern_timeoutthreat (patternscan_time_budget_exhausted), so padding a request cannot skip detection. A directdetect()call outside a request scan is not capped.excludedSemanticDetectionHeaders: an opt-in semantic-only header exclusion (default empty, PR #78). Listed headers skip the semantic analyzer only and the regex scan still runs. It exists onSecurityConfigand onRouteConfig(merged with the global set, also reachable asdetectionExclusion(semanticHeaders: ...)).GuardEngine::applyResponseModifier()and thecustomResponseModifierconfig field (PR #84). The modifier is applied once, after the security headers and before the CORS headers, to every response the engine returns.applyResponseModifier()is the seam adapters call to modify the pass-through responses they send, and an exception inside the modifier leaves the response unmodified.dynamicRuleInterval(default 300, minimum 60, PR #84):execute()calls the dynamic rule manager's interval gate, so the first request fetches and then one fetch runs per interval.- Request metrics (PR #78):
GuardEngine::processResponseemitsresponse_time,request_countanderror_rateonce an agent handler withsendMetricis attached andagentEnableMetricsis on.
Changed
- Behavior to know before upgrading.
enableDynamicRulesandenableEnrichmentnow requireenableAgent, like the referencevalidate_agent_config: a config that sets either withoutenableAgentno longer validates. The client identity, the IP list matching and the IP list precedence follow the reference (see Fixed), so a ban, a rate limit or a list entry can now match a client that it did not match before. A configured whitelist decides alone and the blacklist applies only when no whitelist is configured. Anip_blacklistdynamic rule is now a timed ban instead of a merge into the static list. A dynamic rule version no longer carries over the previous version's changes. A passive-mode suspicious log line without a trigger has new wording, so update any match on the old text. enableDynamicRulesnow lists exactly what a rule changes.
Fixed
- Client identity resolution matches the reference (PR #86, issue #85).
X-Forwarded-Forentries lose their port, empty entries are kept (which changes the entry the depth selects), and a depth that over-counts the proxy hops falls back to the right to left walk. Zoned addresses parse and a mapped zoned address renders as::ffff:1.2.3.4%eth0. A mapped peer inside an IPv4 CIDR intrustedProxiesis no longer trusted and a malformed CIDR no longer stops the search.trustedProxiesaccepts theunixtoken, so a request with no peer address resolves through the chain. A forwarded header from an untrusted peer raises thespoofing_detectedevent and the spoof log once, with a redacted chain. List matching canonicalizes the entry (CanonicalIp::ipInList), so an IPv4 entry, an IPv4 CIDR, a mapped entry and a bracketed entry match a client that connects as IPv4-mapped IPv6. - IP list precedence matches the reference (PR #88, issue #87). The global stage runs the whitelist branch and consults the blacklist only when no whitelist is configured, a configured route whitelist skips the global lists for the request, and
checkRouteIpAccessconsults the route blacklist only when the route has no whitelist. The route country verdict still combines with the route IP verdict. - Dynamic rules apply over the base config (PR #84, issue #83).
ip_blacklistbans through the engine ban manager forip_ban_durationseconds andip_whitelistunbans.blocked_countries,whitelist_countries,block_cloud_providers,blocked_user_agentsandendpoint_rate_limitsreplace the base values. Each new version applies over the base config (guard-core #169): a failed apply keeps the previously active version, expiry restores the base, and patterns already in the pool are not added again. Emergency mode halves the auto-ban threshold in effect, once. The events match the reference envelopes, includingemergency_mode_activated, andSecurityConfig::with()no longer dropsenableAgent,enableDynamicRules,agentEnableEvents,agentEnableMetricsanddynamicRulesCachePath.GuardEngine::initialize()builds theDynamicRuleManagerwhenenableDynamicRulesis on and an agent handler withgetDynamicRules()is attached. - The Cookie header is scanned one
name=valuepair at a time (PR #78, guard-core #162). The;between cookies no longer counts as a command separator in the semantic score, so an ordinary logged-in header is not rejected, while XSS, SQLi and CRLF inside a single cookie are still detected. - Passive-mode suspicious logs keep the reason (PR #78, guard-core #165). A passive line without a trigger reads
[PASSIVE MODE] Suspicious activity detected from <ip>: ... - Reason: <reason> - Headers: ....
Docs
- The examples drop the stale
logSensitiveHeadersaddress-header workaround, which skipped those headers from detection entirely (PR #82).docs/configuration.mdand the agent docs describe the new options.
Chore
v4.3.3
v4.3.3 (2026-10-12)
Parity release (v4.3.3)
A parity release with no functional change: the engine is tagged 4.3.3 so all five core engines (Python, TypeScript, Rust, Go, PHP) ship the same version.
v4.3.2
v4.3.2 (2026-10-09)
The parity-completion release: guard-core-php 4.3.2 tracks the guard-core 4.3.2 release. The engine gains the decorator family (the per-endpoint SecurityDecorator surface with route identity, the shared revision cell, and the engine decorator-handler seam), the SusPatterns runtime pattern registry with the dynamic-rule suspicious_patterns application, the four missing event emitters (cloud_blocked, country_blocked, geo_lookup_failed, security_headers_applied) with the send_cloud_detection_events bus port, the mutedEventTypes/mutedMetricTypes config fields, and the seven previously missing config fields with their consumers.
The decorator family: SecurityDecorator, RouteDecoration, route identity (PR #60)
Added
src/Decorators/SecurityDecorator.php: the 25 config methods (requireIp, blockCountries, allowCountries, blockClouds, bypass, rateLimit, geoRateLimit, usageMonitor, returnMonitor, behaviorAnalysis, suspiciousFrequency, requireHttps, requireAuth, apiKeyAuth, requireAuthorizationHeader, requireHeaders, blockUserAgents, contentTypeFilter, maxRequestSize, requireReferrer, customValidation, detectionExclusion, timeWindow, suspiciousDetection, honeypotDetection) with reference semantics: uppercase country normalization, the block_clouds selector validity gate with the unknown-provider warning, the bypass union with decorator-time leniency, the auth/authorization-header mutual exclusion raising at application time, the ReDoS gate refusing unsafe user-agent patterns before decoration, the body-scan gate for body-reading return patterns, and the parse_qs-shaped honeypot validator with Python truthiness. Plus the base infra: config, routeConfigRevision, getRouteConfig, routeConfigs, initializeBehaviorTracking, initializeAgent, and the five send_*_event emitters over the event bus (decorator_type inside the metadata, the agent_enable_events gate honored), and the getRouteDecoratorConfig static.src/Decorators/RouteDecoration.php: the immutable decoration chain, the port of the decorator closures the mixin methods return; decorate() is the application step. Chaining never mutates the handler or a shared chain, so two factories built from one decorator never bleed into each other (the per-function guarantee).src/Decorators/DecoratedEndpoint.php: the stamped endpoint shell (invokable call-through; pattern shells throw LogicException) andsrc/Decorators/RouteConfigRevision.php: the shared revision cell.- Route identity (the _get_route_id port): the stamped id is reused only when this instance assigned it to this endpoint (stacked decorations share one config); otherwise the base id suffixes #2..#n. New routes seed enableSuspiciousDetection from enablePenetrationDetection like _ensure_route_config; every applied decoration bumps the shared cell.
GuardEngine::setDecoratorHandler(the set_decorator_handler port): requests stamped with a route id resolve their RouteConfig through the handler's registry (the get_route_decorator_config flow), and decorator mutations rebuild the pipeline through the revision staleness seam.BehaviorRuleValidation::validateReturnPatternAgainstScanFlag: the single-pattern scan gate (the _validate_return_pattern_body_scan path), with the list validator refactored onto it. Decorated endpoints flow through the identical pipeline checks: adapters hand the registry (SecurityDecorator::routeConfigs()) to the existing path-pattern route maps, or stampstate->guardRouteIdand let the engine resolve through the handler.
The runtime pattern registry and the seven config fields (PR #57)
Added
- SusPatterns grows the runtime registry: addPattern/removePattern with the ReDoS safety gate, getDefaultPatterns/getCustomPatterns/getAllPatterns, catalog-row removal tracked as a scan-time skip, and detectPatternMatch (the reference detect_pattern_match projection). Registry additions scan on every view with no context filter and the custom category (the reference compiled, _CTX_ALL, "custom" tuple semantics), with the same per-pattern scan gates and monitor metrics as a catalog row.
- The custom pool persists to Redis ("patterns:custom", comma-joined) and restores on engine initialize(); unsafe persisted rows are skipped with a warning. pattern_added / pattern_removed events flow through the event bus with handler_name sus_patterns, the system ip, and the redacted source, pool type and pool size. The engine owns the detection engine instance, so the registry survives applyDynamicConfig pipeline rebuilds; GuardEngine exposes susPatterns().
- DynamicRuleManager applies suspicious_patterns through the registry (unsafe rows warned and rejected) instead of the skip warning; the reference's expiry semantics carry (registrations are runtime detection state, not config). SuspiciousActivityCheck exempts the custom category from the enabled-categories gate, mirroring _pattern_should_be_skipped.
- The seven missing SecurityConfig fields with reference defaults, validation, and consumers: detection_threat_score_threshold (1.0, [0.0, 10.0], the detect() verdict gate on the regex-threat anomaly), detection_pattern_validation_cache_path (the disk-backed cost-verdict cache wired into the registry gate, the dynamic-rule user-agent validation and the CheckFactory fallback engine; cheap deterministic layers always re-run), log_country_check_level (INFO, nullable, the whitelisted and not-affected country verdict lines plus the reference debug lines for the loopback and no-geolocation skips), redis_health_check_interval (30, ge 0, the RESP idle-socket PING probe with recycle-on-failure), redis_max_connections (null, ge 1, the pool cap on the recycle path), redis_retries (1, ge 0, the capped-backoff retry wrapper over transient connect/write/read failures, never server-side error replies), and agent_strict (false, the strict agent attach: a handler without the sendEvent surface raises instead of degrading to agent-off).
The four event emitters and send_cloud_detection_events (PR #58)
Added
EventBus::sendCloudDetectionEvents, the reference middleware_events.py port: the cloud_blocked verdict event (handler cloud, action request_blocked or logged_only in passive mode, the provider and network metadata) plus, when a forwarded route blocks clouds, its access_control decorator_violation.- The cloud_provider check emits the bus cloud events on every block path and its own block_clouds decorator_violation for a route-level block (the reference check passes a null route to the bus for exactly that case). The ip_security check's country stage emits country_blocked (handler ipinfo, the reference allowlist and blocklist reasons with the country and rule_type metadata) and geo_lookup_failed (a failing resolver reports the lookup failure and a miss, never a raise, mirroring get_country's internal catch).
- security_headers_applied emits behind the reference security-headers TTL cache (TTLCache(1000, 300) semantics: one event per config+path per window keyed cfg_{id}path{sha256 prefix}, empty paths gated, oldest-first trim matching the single-TTL eviction order), at the pipeline pass (the headers the adapter applies to the pass-through response) and on blocked responses. The events close the H3 and H6 event-deferral notes and give DynamicRuleManager.matchEvent a live cloud_blocked stream to correlate against.
Muted event and metric types as config fields (PR #59)
Added
- SecurityConfig grows mutedEventTypes / mutedMetricTypes (validated string lists, with()-safe, the reference default of the empty set, exact identifier strings). EventBus derives its EventFilter from the config's mute lists when none is injected; an explicitly injected filter still wins (the AgentHandlerComposer wiring keeps precedence).
Coverage
Changed
- The new surfaces join the 100%-line coverage gate:
bin/test_decorators.php(210 assertions, mirroring the reference decorator tests: test_base, test_mixins, test_route_id_per_function, the access-control country/cloud/bypass arms, the advanced honeypot matrix, the detection-exclusion arms),bin/test_pattern_registry.php(80),bin/test_config_fields.php(69), andbin/test_events_parity.php(55) join the CI test steps, the coverage collect list, and the Makefile runners. The ip_ban initialization catch waiver moves with its shifted lines in.github/coverage-unreachable.php(same block, same proof).
v4.3.1
v4.3.1 (2026-10-07)
The observability-seam release: guard-core-php 4.3.1 tracks the guard-core 4.3.1 release. The engine gains the event enrichment layer (ThreatScorer + EventEnricher stamping the reference guard.* metadata family), the CompositeAgentHandler multi-sink fan-out with the OTel (OTLP/HTTP JSON) and Logfire handlers, JSON structured logging with the log_format config switch, the GuardCoreError base exception, the setAgentHandler agent seam, and the per-component initialization status surface.
Event enrichment: ThreatScorer + EventEnricher (PR #55)
Added
- The deterministic threat-score map (
src/Events/ThreatScorer.php), same values and order as the reference_THREAT_SCORE_MAP: 90penetration_attempt, then the attack-family events down to 10 for the lifecycle events, with default 20 for unmapped types. The map feeds theguard.threat_scoreenrichment key, so every enriched event carries its severity weight from the same table the reference scores with. - The enrichment layer (
src/Events/EventEnricher.php+EnrichmentContext.php), the four reference steps in reference order, stamped into the eventmetadataunder the exactENRICHMENT_KEY_*strings added toEventTypes.php:guard.project_id,guard.service.name,guard.deployment.environment,guard.threat_score,guard.rule.id,guard.rule.version,guard.behavior.correlation_key,guard.behavior.recent_event_count. Identity (project id when configured, service name always, deployment environment fromotel_resource_attributes["deployment.environment"]), threat score from the event type, dynamic-rule correlation via the duck-typedmatchEvent([ruleId, version]), and behavior correlation:BehaviorTracker.getRecentEventCount(ip, 300)(mirroring the referenceget_recent_event_count) plus thesha256(ip|service|bucket)[:16]correlation key withbucket = floor(now/300). Failure semantics: any enrichment error logs and the event flows onward; the immutable PHP records make the failure path atomic (the original event is returned), matching the reference's stated intent (its in-place dict mutation can survive a later step's failure despite the "sent unenriched" log line, a divergence recorded in the PR). Enrichment lives in the composite only, as in the reference; the bare agent-handler path is untouched. New config fields with reference defaults:enableEnrichment=false,agentProjectId,otelServiceName="guard-core",otelExporterEndpoint,otelResourceAttributes={},enableOtel=false,enableLogfire=false,logfireServiceName="guard-core".
Composite, OTel, and Logfire handlers (PR #55)
Added
src/Events/CompositeAgentHandler.php: the multi-sink fan-out port of the referencecomposite_handler.py, with the event/metric filter, one enrichment pass before the fan-out (all sinks observe the same enriched instance), per-sink exception isolation (degraded, not dead), thestarted()/degraded()/failedHandlers()lifecycle, best-effortstop()/flushBuffer()/initializeRedis(), first-non-nullgetDynamicRules()and all-answeringhealthCheck().src/Events/AgentHandlerComposer.phpmirrors the referencebuild_enricher+build_composite_handler: sinks[agent] + OtelHandler (enable_otel) + LogfireHandler (enable_logfire), enricher whenenable_enrichment.src/Events/OtelHandler.php(+OtlpTransport.php/OtlpHttpTransport.php): theotel_handler.pyport speaking OTLP/HTTP JSON through an injectable transport (production defaults to curl-or-streams with bounded timeouts, tests use a capturing fake). Faithful surface: the exact_otlp_signal_endpointsuffix logic, resourceservice.nameplus extra attributes, spanguard.event.<type>with theguard.*attribute family, enrichment forward (theguard.*metadata minus traceparent/tracestate), W3C traceparent extraction as the remote parent (malformed falls back to a generated traceId), the metrics mapping (response_time->guard.request.durationhistogram in seconds,request_count->guard.request.count,error_rate->guard.error.count, unknown types log and record nothing), and export failures logged never raised. Divergence: the reference's process-global tracer/meter provider claim-and-shutdown dance is inapplicable-idiom in PHP (no global OTel registry; each handler owns its transport, per-instancestart()stays idempotent).src/Events/LogfireHandler.php: the exact reference lifecycle over an injected duck-typed client (configured()/configure()/span()/info()/shutdown()): no client means disabled warning + no-op + unhealthy, an already-configured client means a warning naming the service name that will NOT be applied (configure is not called), andstop()shuts down only what guard configured. Emission: spanguard.event.<type>with event fields plus the enrichment forward, infoguard.metric.<type>with value/endpoint/safe tags.
JSON structured logging (PR #55)
Added
src/Logging/JsonFormatter.php(exactly four fields in reference order:timestampin the python asctime shape,leveluppercase,logger,message; malformed UTF-8 substitutes instead of failing the encode) andsrc/Logging/LogSetup.php(setupCustomLoggingreturning an engine-compatibleRequestLogger, the text layout mirroring[%(name)s] %(asctime)s - %(levelname)s - %(message)s, the INFO+ gate, on-demand directory creation with console-only fallback, console emission writing bare lines to stderr). The config switch islog_format(text|json, validated at construction,with()-safe) withLogSetup::engineLogClosure()for adapters. The referencebody_reader.pyextras (read timeout, concurrency cap, straddle over-read) are documented as inapplicable-idiom for the SAPI model, not ported.
Engine seams: GuardCoreError, agent hook, initialization status (PR #54)
Added
GuardCoreErrorbase exception:GuardRedisException,UnsupportedFeatureErrorandMmdbErrornow extend it, giving adapters one catch type for any guard-core failure, mirroring the Python engine.- The agent seam (W2):
GuardEngine::setAgentHandlerforwards to the event bus and queued events drain on attach. This is the hook the four adapter repos' agent integrations consume. GuardEngine::initializationStatus(): per-component enabled/ok/error recorded acrossinitialize(), the payload the adapters' status routes serve. Failure semantics unchanged: the original exception still throws, the status is recorded before the throw.
Coverage
Changed
- The new surfaces are covered to the 100%-line gate:
bin/test_enrichment.php(56 assertions),bin/test_composite_handlers.php(96),bin/test_json_logging.php(34), andbin/test_parity_seam.php(7) join the CI runner list, with the provably-unreachable inventory in.github/coverage-unreachable.php(composite sink-failure twins, the OTel attribute-type forward skips, the closed-stderr fallback, the double-enrich idempotence arms).
v4.3.0 (2026-10-01)
The safety-corpus release: guard-core-php 4.3.0 tracks the guard-core 4.3.0 release. The engine gains the spec 12 event bus with dynamic rules and Redis-backed metrics persistence, the spec 10 geo download and refresh lifecycle, the spec 04 ReDoS safety gates over PCRE plus the performance monitor, and the three new spec 4.1.0 conformance runners (pattern_safety, events, redis_interop) with fail-closed xfail baselines; the whole suite now sits behind a 100%-line coverage gate (every executable line covered or provably unreachable), and the hex-escape attack indicator carries the reference's parity backslash again, unbreaking PHP 8.5's stricter PCRE2.
Event bus, metrics collector, and dynamic rules (spec 12)
Added
- The spec 12 parity port: EventBus, EventFilter, MetricsCollector, SecurityMetric, DynamicRuleManager and DynamicRules land engine-side, with the SecurityConfig agent gating (
agent_enable_events/agent_enable_metrics, both default true) and the newdynamic_rules_cache_pathfield. Ported from the reference event-bus surface at the spec 12 parity commit (PR #43, cherry-pick of 136f35b); both new suites are registered in the CI test steps and the coverage collect list. Coverage lives in the newbin/test_event_bus.phphonesty runner (136 checks at the port). - Metrics persist across requests via Redis, closing the last metrics gap: the spec 12 event bus is queueable, but MetricsCollector was in-memory only, so under the shared-nothing runtime model metrics emitted with no agent handler died with the request (PR #48). The collector's queueing ports onto the reference key semantics (spec 08 namespaced grammar, full_key = prefix + namespace + ':' + key): with no agent handler and a wired RedisHandler the pending backlog lives under
{prefix}metrics:pendingas a JSON list of SecurityMetric payloads (timestamp ATOM, metric_type, value, tags).setAgentHandler()dispatches the in-memory queue, then flushes the persisted backlog in order and clears the key, anddrain()absorbs the persisted backlog too, so FPM adapters can deliver it themselves. Failure arms stay fail-closed and quiet: a corrupted or non-list payload is discarded with a log (empty backlog, never raised), malformed entries are skipped individually with a log, and Redis write failures log and never raise;agent_enable_metricsand the EventFilter gate before anything persists, and a disabled RedisHandler falls back to the in-memory queue.bin/test_event_bus.phpgrows the persistence arms (150 -> 174 assertions): backlog accumulation across two sends, flush-on-attach with valu...
v4.3.0
v4.3.0 (2026-10-01)
The safety-corpus release: guard-core-php 4.3.0 tracks the guard-core 4.3.0 release. The engine gains the spec 12 event bus with dynamic rules and Redis-backed metrics persistence, the spec 10 geo download and refresh lifecycle, the spec 04 ReDoS safety gates over PCRE plus the performance monitor, and the three new spec 4.1.0 conformance runners (pattern_safety, events, redis_interop) with fail-closed xfail baselines; the whole suite now sits behind a 100%-line coverage gate (every executable line covered or provably unreachable), and the hex-escape attack indicator carries the reference's parity backslash again, unbreaking PHP 8.5's stricter PCRE2.
Event bus, metrics collector, and dynamic rules (spec 12)
Added
- The spec 12 parity port: EventBus, EventFilter, MetricsCollector, SecurityMetric, DynamicRuleManager and DynamicRules land engine-side, with the SecurityConfig agent gating (
agent_enable_events/agent_enable_metrics, both default true) and the newdynamic_rules_cache_pathfield. Ported from the reference event-bus surface at the spec 12 parity commit (PR #43, cherry-pick of 136f35b); both new suites are registered in the CI test steps and the coverage collect list. Coverage lives in the newbin/test_event_bus.phphonesty runner (136 checks at the port). - Metrics persist across requests via Redis, closing the last metrics gap: the spec 12 event bus is queueable, but MetricsCollector was in-memory only, so under the shared-nothing runtime model metrics emitted with no agent handler died with the request (PR #48). The collector's queueing ports onto the reference key semantics (spec 08 namespaced grammar, full_key = prefix + namespace + ':' + key): with no agent handler and a wired RedisHandler the pending backlog lives under
{prefix}metrics:pendingas a JSON list of SecurityMetric payloads (timestamp ATOM, metric_type, value, tags).setAgentHandler()dispatches the in-memory queue, then flushes the persisted backlog in order and clears the key, anddrain()absorbs the persisted backlog too, so FPM adapters can deliver it themselves. Failure arms stay fail-closed and quiet: a corrupted or non-list payload is discarded with a log (empty backlog, never raised), malformed entries are skipped individually with a log, and Redis write failures log and never raise;agent_enable_metricsand the EventFilter gate before anything persists, and a disabled RedisHandler falls back to the in-memory queue.bin/test_event_bus.phpgrows the persistence arms (150 -> 174 assertions): backlog accumulation across two sends, flush-on-attach with value/tags/timestamp round trip plus key clearing, drain absorption and empty-drain, redis write failure, corrupted/non-list/scalar pending payloads, malformed-entry skipping with tag coercion, the enable gate, and the disabled-redis fallback; every new MetricsCollector line is executed with a behavioral assertion.
Geo download and refresh lifecycle (spec 10)
Added
- The spec 10 parity port: IpInfoManager owns the geo database lifecycle (PR #43, cherry-pick of 0fbbdb5). Token download (3 attempts, exponential backoff), atomic writes, mtime freshness, the Redis-cached database copy, never-raising
get_country,check_country_accessverdicts withcountry_blockedevents,geo_lookup_failedevents, and the Redis-backed single-flight refresh lock. Coverage lives in the newbin/test_geo_lifecycle.phphonesty runner (100 checks after the #44 coverage arms, 92 at the port) over the new sharedtests/MmdbFixture.phpfixture; master'sbin/test_geo_country.phpkeeps its own superset inline builder, and the shared fixture serves the geo-lifecycle suite exclusively (each suite runs in its own PHP process, so no redeclare).
Spec 04 ReDoS safety gates on PCRE, and the performance monitor
Added
- The section 04 ReDoS safety gates port to the PHP engine's PCRE model, plus the performance monitor (spec 04) and the event constant table (PR #39, mandated by specs/impl/php.md's regex-engine section). Structural prefilters (the three reference dangerous-construct regexes verbatim, the compile check, and the nested-unbounded / adjacent-broad structural checks over a source-only scan, outputs pinned case-by-case against the Python functions); reach-probe synthesis (a subject that reaches every quantified region, null synthesizing a fail-closed reject; the reference's subprocess probe and slot/interval machinery adapted per the spec's open-questions note to the observable contract: reject slow, fail closed, bounded time); the cost arbiter (load factor floor 0.25 / ceiling 8.0 measured against the reference scan, scaled verdict deadline, the 4000..32000 ladder, the min16/min32 verdict rule with noise floor 0.001, growth per doubling with extrapolation to the cap against the 0.05 s budget, one confirming retry, the interactive probe at 0.05 s per string and 2.0 s overall fail-closed, stride-sampled probe sets, and the accept-with-disagreement log); the scan guard (per-scan
pcre.backtrack_limitviaini_setwith the previous limit saved/restored,PREG_BACKTRACK_LIMIT_ERROR/PREG_RECURSION_LIMIT_ERROR/PREG_JIT_STACKLIMIT_ERRORtrips classified as scan timeouts that fail closed while other preg failures keep the engine's fail-secure 500, an hrtime deadline, the consecutive-timeout counter and pool-replacement warning, and a canary probe - the arbiter's verdict arithmetic over doubling subject prefixes - before plain-pattern scans of large subjects); and the SusPatterns classification and timeout semantics (scan-window bounded patterns unguarded, windowed finders under the full compiler timeout, plain patterns behind the canary plus the reference's 0.9x-timeout heuristic arm, and the exact reference timeout semantics: a scan timeout emits apattern_timeoutthreats-logged-and-miss threat with the category weight, so an engine that could not finish a scan fails closed;detect()now also returnstimeouts).PregFailurecarries the preg error code captured at throw time so classification never reads stale process-global state. Coverage lives in the newbin/test_redos_gates.php(175 checks: allow and block paths, gate-fire assertions, and the catastrophic fixtures(a|a)*$,(.*a){20}$,(aa|a){20}$over adversarial subjects tripping the engine budget and the deadline deterministically, hrtime-measured in the test) andbin/test_performance_monitor.php, both registered in the MakefileRUNNERSlist, the CI test matrix and the coverage collection.
Spec 4.1.0 conformance runners: pattern_safety, events, redis_interop
Added
- The three new reference corpus suite kinds are vendored and replayed with one runner per kind, mirroring the existing fail-closed xfail architecture (PR #46, fixes #45). The vendored corpus gains
safety_gates.json,event_stream.jsonandredis_interop.jsonplus the updatedindex.json(the new comparison blocks: events envelopes with volatile-field dropping, redis interop byte-equality rules, pattern-safety record rules).bin/conformance_safety.phpruns every case through the realPrefilters::validatePatternSafetygate chain and maps verdict reasons to the corpus reason classes with the corpus generator's CLASS_RULES (first-match order), comparingexpected.safe+expected.reason_classonly.bin/conformance_events.phpdrives the real PHP event seams (the spec 12 pipeline bus, the monitor anomaly emitters, the geo event sink, the dynamic rules manager, the ban manager) and compares full envelopes per the events_envelopes block: expected keys only, volatile fields dropped recursively, fields the PHP envelope model cannot observe recorded as null.bin/conformance_redis.phpperforms one live-Redis operation per key family under each case's prefix (flushed before and after) with byte equality for keys, static values and TTL semantics, shape pinning for engine-generated zset members, and parsed-then-canonicalized JSON comparison. Each kind carries a fail-closed xfail baseline (php_safety_xfail.json,php_events_xfail.json,php_redis_interop_xfail.json): a failing case not baselined is red, a baselined case that passes is red (stale), and a driverless case not baselined is red, every baseline entry naming the PHP source of the divergence. Baselines at the merge: conformance_safety 69 passed / 25 xfail (unported parse-slot structural rules - ambiguous tail, unreachable terminator, literal absorb - PCRE2 atomic-group compile acceptance and auto-possessification, and the two timed cost-arbiter disagreements), conformance_events 6 passed / 32 xfail plus 1 advisory corpus skip (handler-level emitters not ported: sus_patterns, ban envelopes, security headers, redis manager, ip extraction, decorators, dynamic-rule violations, the check-hook metadata shape, and the blockEventType default mapping), conformance_redis 7 passed / 7 xfail (unported security-headers / custom-pattern / cloud-ranges registries, the verbatim vs latin-1 GeoIP database cache, and the ATOM vs Z snapshot timestamp).
100% line coverage gate, and the process scaffold
Added
- The suite sits behind a 100%-line coverage gate: every executable line covered or provably unreachable (PR #38 and the follow-up coverage campaign, closed together with #44's geo + events arms).
.github/coverage-runner.phpcollects pcov line coverage per suite in--collectmode (extra suite args such as test_state's--integrationforwarded) and a--mergepass aggregates every collection and gates. Coverage climbed 80.56% -> 89.19% -> 98.86% -> 99.08% across the campaign (~1650, then ~1400, then ~100 test LO...
v4.2.0
v4.2.0 (2026-09-27)
The parity release: guard-core-php 4.2.0 tracks the guard-core engine 4.2.0 release. This train closes the remaining pipeline divergences from the spec 4.1.0 corpus (the on_block hook payloads now carry the reference log-format reasons and the route-level IP and country rules are enforced), and the fail-closed xfail registry is emptied.
Note on the 4.1.0 tags: the guard-core 4.1.0 family tags were a version-accuracy error and were yanked/unpublished (the 4.1.0 work itself is part of this 4.2.0 train; Packagist serves 4.2.0 from the v4.2.0 tag). The conformance corpus vendored here is the spec 4.1.0 corpus (219 cases across the detect and pipeline suites) and the pipeline gate runs green against it with an empty divergence registry.
on_block corrections and route-level IP enforcement
Fixed
- The on_block hook payloads now carry the reference log-format reasons with an empty trigger_info (the reference checks pass no trigger_info to log_activity), and route-level IP rules are enforced for the first time. ip_security composes
IP not allowed: {ip} - {access reason}over the access_control generic list block string andIP not allowed by route config: {ip}for route denials, rate_limit reportsRate limit exceeded for IP: {ip} ({count} requests in {window}s window)from the tripped tier, and suspicious_activity emitsSuspicious activity detected for IP: {ip} - {trigger_info}active /Suspicious activity detected: {ip}passive with the detection trigger built from the reference component labels (Request body / Header / URL path / JSON key / Request body field) plus the first threat message; passive-mode dispatches fire the hook inline with a null status_code like the reference_dispatch_block_hook. The enforcement fix: route-level ip_whitelist (and ip_blacklist / route country rules) were absent fromRouteConfigand the ip_security check entirely, so a non-whitelisted client passed where the reference denies 403.RouteConfiggains theipWhitelist/ipBlacklist/blockedCountries/whitelistCountriesfields with the route blacklist first, a configured route whitelist taking over the route verdict, the route country verdict combining with it, and a route IP whitelist clearing the identity flags without relaxing the global lists, with a regression test throughGuardEngine::execute. The wildcard + credentials CORS combination is accepted at construction and downgraded at policy resolution (credentials dropped, wildcard answers without the allow-credentials header), and the CORS response surface composes methods/headers/3600 max-age like the reference_build_cors_headers. The 27 xfail entries are cleared leaving an empty fail-closed registry (pipeline gate: 35 passed, 0 failed, 0 xfail, 0 config divergences).
Verification
- Full suite green on PHP 8.3 (Docker, php:8.3-cli + throwaway redis:7-alpine, no host php/composer):
bin/conformance.php184/184 (verdicts unchanged),bin/conformance_pipeline.php35 passed / 0 failed / 0 xfail / 0 config divergences over the spec 4.1.0 corpus, and the fullbin/runner list per the CI workflow.
Per-route detection exclusions
Added
- Per-route detection exclusion surface: the detection-exclusion fields of the reference
RouteConfigare now honored by thesuspicious_activitycheck instead of being absent. Ported from guard_coredecorators/route_config.py(the detection-exclusion fields),_utils/detection_config.py(_resolve_excluded_params/_resolve_excluded_body_fields/_resolve_enabled_categories/_resolve_scan_body/_resolve_excluded_headers) andcore/checks/helpers.py(_get_effective_penetration_setting), structurally following the guard-core-go detection-exclusion resolution:RouteConfiggainsenableSuspiciousDetection(default true),excludedDetectionHeaders,excludedDetectionParams,excludedDetectionBodyFields(null = inherit the global config; a non-null set replaces the global one, the header set being the exception: it always merges the hardcoded proxy-identity defaults with the global set and the route set),enabledDetectionCategories(null = inherit; a non-null set replaces the global set, an empty list disables every category like the reference's empty frozenset) anddetectionScanBody(null = inherit the new globaldetectionScanBodyfield, default true; a false skips the request-body surface only while the URL path, query and headers still scan). The per-routeenable_suspicious_detectiondecorator wins over the global flag for routed requests (route true enables detection even with the global flag off, route false disables it even with the flag on, the reference'sdisabled_by_decoratormiss), and the reference's route-awareapplies_togate now schedules the check when any registered route enables detection with the global flag off. Entries keep the repo's verbatim-exclusion convention (scan sites lower the scanned name or key and test membership), and route-excluded headers keep the excluded-header ssrf-only skip semantics (they suppress an ssrf address chain only; every other category still scans them). Coverage lives in the newbin/test_route_detection_exclusions.phphonesty runner (37 assertions: the route kill switch, the factory-level scheduling gate, the per-request gate, each exclusion surface's replace/merge semantics, category narrowing, body-surface skips with the query surface still active, exclusion-scoping and bypass precedence, and config/routewith()immutability) registered in the MakefileRUNNERSlist and the CI workflow.
Deferred
- Engine-level scheduling for route opt-in with the global flag off: the PHP pipeline is built without a route registry (adapters attach
state->routeConfigper request), soCheckFactory::buildChecksis called without route configs and a route enabling detection alone cannot schedule the statically-built check. The route-awareappliesTogate is implemented and pinned at the factory level, mirroring how the geo rate limit surface handled the same shape.
Verification
- Full suite green on PHP 8.3 (Docker, php:8.3-cli + throwaway redis:7-alpine), mirroring the CI workflow step for step:
composer install,make lint(LINT_OK +composer validate --strict),make testexit 0 over every runner, and the newbin/test_route_detection_exclusions.php37/37.
Behavior rules
Added
- Behavior rules: the engine now runs the reference behavioral surface instead of leaving it out. Ported from guard_core
_security_config_fields.py(the behavior fields),_security_config_field_validators.py(BehaviorRuleConfigand the fail-closed scan validation),handlers/behavior_handler.py(sliding windows),handlers/_behavior_action_dispatch.py,handlers/_behavior_response_pattern.py,handlers/_behavior_json_pattern.pyandcore/behavioral/processor.py, structurally following guard-core-go #26. Config surface:globalBehaviorRules(rules applied to every route, in addition to any route-specific rules),behaviorScanResponseBody(default off: zero behavior change unless enabled) andbehaviorMaxResponseBodyInspectBytes(default 262144, the reference ge/le bounds 1024..10485760, a configured 0 normalizes to the default). Rule validation mirrors the pydantic constraints fail-closed at construction:rule_typein usage/return_pattern/frequency,threshold>= 1,windowdefault 3600 (a configured 0 falls back),actionin ban/log/throttle/alert defaulting to log,ban_duration>= 1 or null, and the reference's fail-closed return_pattern check: a body-reading (non-status:) pattern with the scan flag off fails construction with the reference message, because the rule would silently never match;with()re-validates the copy. Route surface:RouteConfigacceptsbehaviorRules(the referenceroute_config.behavior_rules), entries failing the constraints silently dropped (decorator-time leniency like the other route maps). Request side:GuardEngine::executeruns usage/frequency rules on requests the pipeline allowed (the reference's process_usage_rules), tracking through sliding windows that are strictly-greater than the threshold, backed by Redis when enabled over the shared key layout (behavior_usage/behavior_returnsnamespaces with SHA-256 identity-segment hashing exactly like_hash_identity_segment, so the Python and PHP engines share keys) and by bounded local maps otherwise (10,000 endpoint buckets and client rows, oldest-tracked eviction standing in for Python's LRU), failing closed on Redis errors unlessredisFailOpen. Response side: the newGuardEngine::processResponse($request, $response)runs the route's and the config's return_pattern rules for an adapter's outgoing pass-through response, mirroring the reference response factory's behavioral phase; return rules never modify the response. Pattern surface:status:<code>matches on status_code alone,json:<path>==<expected>walks decoded JSON with case-insensitive comparison and a[]segment matching any array element,regex:<pattern>matches case-insensitively, and a bare substring matches case-insensitively; body patterns read at most the leadingbehaviorMaxResponseBodyInspectBytesof the response body and are only evaluated whenbehaviorScanResponseBodyis on. Action dispatch mirrors_behavior_action_dispatch: passive mode only logs the[PASSIVE MODE]lines, active mode bans with the rule'sban_durationor the 3600s fallback (reasonbehavioral_violation), alerts, or logs at thelog_suspicious_level.correlate_with_detectionhalves the effective threshold for global return rules (min 1) while the IP has prior detection-category hits, driven by a new shared per-IP per-category suspicious-count store thesuspicious_activitycheck writes (mirroring ...
v4.0.4: First stable release, full parity with guard-core 4.0.4
v4.0.4 (2026-09-24)
First stable release, full parity with guard-core 4.0.4 (v4.0.4)
Added
- Full parity with the Python guard-core 4.0.4 engine: all 17 security checks ported and passing. The behavioral WAF engine now matches the reference engine check-for-check (route config, emergency mode, HTTPS enforcement, request logging, request size and content, required headers, authentication, referrer, custom validators, time window, cloud IP refresh, IP security, cloud provider, user agent, rate limit, suspicious activity, custom request).
- Binary-noise gates over the detection engine, including the SQLi comment-terminator binary gate. Noise-prone pattern matches inside binary body content are discarded before reporting, and SQLi patterns that terminate on comment-like byte sequences are explicitly gated in binary bodies, closing the last detection divergence against the reference corpus.
- Conformance corpus harmonized to spec 4.0.3. The fixture corpus (
tests/Conformance/) carries the reference engine's expected values, including binary-body vectors, and must never be hand-edited.
Changed
- First stable release on Packagist. The package installs as
rennf93/guard-core-php(PHP 8.2+, platform-only dependencies: pcre, mbstring, json); the version is derived from the git tag, and this v4.0.4 tag is the first stable, non-pre-release publish. The earlier v0.1.0 tag was a burned pre-release snapshot and is superseded by this release.
Verification
- Interop harness against the reference engine: 82/82 vectors passing.
- Full suite: the eight
bin/runners plusbin/conformance.phpacross PHP 8.2, 8.3 and 8.4 (CI and the Release Gate workflow at the tag).