Guard Core PHP: the API security core engine for PHP. A framework-agnostic port of the guard-core detection engine with Redis-backed rate limiting, IP policy, and payload inspection.
Website · Docs · Playground · Dashboard · Discord
Guard Core is the Python engine. Framework adapters are thin wrappers that translate native request/response types into Guard Core's protocols. The telemetry agents ship security events and metrics to the monitoring backend. Parallel engine implementations exist for Go, PHP, TypeScript (on npm), and Rust (on crates.io) - all ports of the same reference semantics, conformance-tested against the shared adversarial corpus.
| Package | Role | PyPI |
|---|---|---|
| guard-core | Framework-agnostic security engine | |
| guard-agent | Telemetry agent | |
| fastapi-guard | FastAPI / Starlette adapter | |
| flaskapi-guard | Flask adapter | |
| djapi-guard | Django adapter | |
| tornadoapi-guard | Tornado adapter |
Go modules published via GitHub releases. Production-ready.
| Package | Role | Release |
|---|---|---|
| guard-core-go | Go engine | |
| nethttp-guard | net/http adapter | |
| gin-guard | Gin adapter | |
| echo-guard | Echo (v4) adapter | |
| fiber-guard | Fiber (v3) adapter | |
| guard-agent-go | Telemetry agent |
Published on Packagist under the rennf93 vendor. Production-ready.
| Package | Role | Packagist |
|---|---|---|
| guard-core-php | PHP engine | |
| laravel-guard | Laravel adapter | |
| symfony-guard | Symfony adapter | |
| psr15-guard | PSR-15 adapter | |
| slim-guard | Slim 4 adapter | |
| guard-agent-php | Telemetry agent |
Published under the @guardcore npm scope; source in the guard-core-ts monorepo. Production-ready.
| Package | Role | npm |
|---|---|---|
| @guardcore/core | Core engine | |
| @guardcore/express | Express adapter | |
| @guardcore/nestjs | NestJS adapter | |
| @guardcore/fastify | Fastify adapter | |
| @guardcore/hono | Hono (edge) adapter | |
| guardagent | Telemetry agent |
Published on crates.io. Production-ready.
| Package | Role | crates.io |
|---|---|---|
| guard-core-engine | Core engine crate | |
| guard-core-rs | Facade crate (consumer entry point) | |
| actix-guard-rs | Actix Web adapter | |
| axum-guard-rs | Axum adapter | |
| tower-guard-rs | Tower adapter | |
| rocket-guard-rs | Rocket adapter | |
| guard-agent-rs | Telemetry agent |
| Package | Role | PyPI |
|---|---|---|
| guard-core-mcp | MCP server: config validation, docs search, detection sandbox |
- IP lists: whitelist, blacklist, and exemptions with CIDR support
- Geo rate limits and country blocking: per-country rules via GeoIP
- CORS handling and security headers: OWASP-aligned defaults
- Behavior rules: per-route usage counting and bans
- Per-route detection exclusions and detection limits (ReDoS-safe custom patterns)
- Redis-backed distributed state with fail-open / fail-secure modes
📚 Documentation - full technical documentation for this package.
🛡️ Guard Core - the engine's reference documentation.
🤖 Monitoring Agent Integration - monitor your Guard instance with a monitoring agent.
composer require rennf93/guard-core-php:^4.0.4Requires PHP ^8.2 with ext-pcre, ext-mbstring, and ext-json. The engine is consumed through the adapter packages or driven directly:
use RenzoFranceschini\GuardCore\Config\SecurityConfig;
use RenzoFranceschini\GuardCore\Engine\GuardEngine;
$config = new SecurityConfig(
enableRedis: false,
blacklist: ['192.0.2.0/24'],
rateLimit: 100,
rateLimitWindow: 60,
enableRateLimiting: true,
);
$engine = new GuardEngine($config);whitelist and exempt_ips answer different questions. A non-empty whitelist is restrictive: every IP not on it is denied by the global IP check. exempt_ips is noise reduction for known-friendly automation (monitoring probes, VPN egress, a partner's server): a listed IP or CIDR skips the rate-limit, user-agent and per-route cloud-provider checks, but it is not immunity. The blacklist, dynamic IP bans, the global block_cloud_providers list and penetration detection still apply to exempt IPs, the whitelist deny path is unchanged (an exempt IP does not pass a restrictive whitelist it is not on), and an invalid entry fails closed at config construction. Entries accept IPv4, IPv6 and IPv4-mapped forms with the same matching semantics as the whitelist.
$config = new SecurityConfig(
enableRedis: false,
exemptIps: ['198.51.100.7', '198.51.100.0/28'],
);
$engine = new GuardEngine($config);Routes can carry per-country rate-limit tiers: RouteConfig::$geoRateLimits maps a country code ('DE') or the '*' fallback to a {limit, window} tier, mirroring the reference engines' @geo_rate_limit decorator. Country resolution is pluggable and the tiers only activate when a country resolver is configured on the rate limit handler: without a resolver the geo tier is inert and the default limit applies (a route can carry the map, but nothing fires until one is wired). The resolver is a Closure(string): string from client ip to country code (empty string when unknown, which takes the '*' fallback); adapters wire it after engine construction:
$engine = new GuardEngine($config);
$engine->rateLimitHandler()->setGeoResolver(
static fn (string $ip): string => $ipinfo->countryOf($ip) // your geo lookup
);
$request->state()->routeConfig = new RouteConfig(
geoRateLimits: ['DE' => ['limit' => 5, 'window' => 60], '*' => ['limit' => 20, 'window' => 60]],
);A request from a resolved country enforces that country's tier first ('*' when the country is missing from the map, nothing when neither matches), the tier shares the route's hashed bucket, and exempt and whitelisted clients still skip the check entirely.
Set blockedCountries and/or whitelistCountries and the ip_security check enforces them after the global IP lists: a non-empty whitelistCountries is restrictive (only listed countries pass, an unresolved country is denied), blockedCountries denies its matches, loopback IPs are exempt, and a global whitelist match skips the country stage entirely. Country rules with no resolver fail config construction: point geoIpDbPath at a locally provisioned MMDB file with top-level country records (the ipinfo country_asn.mmdb layout) or inject a CountryResolver. The engine never downloads databases.
use RenzoFranceschini\GuardCore\GeoIp\CountryResolver;
final class MyGeoIp implements CountryResolver
{
public function getCountry(string $ip): ?string
{
return $this->mmdb->countryOf($ip); // your lookup, null when unresolved
}
}
$config = new SecurityConfig(
enableRedis: false,
blockedCountries: ['CN', 'RU'],
geoIpDbPath: __DIR__ . '/country_asn.mmdb', // or geoIpHandler: new MyGeoIp(),
);Set enableCors: true and the engine runs the reference CorsHandler behavior: a preflight (OPTIONS carrying Access-Control-Request-Method) executes the security pipeline and is short-circuited with 200 OK or 400 Disallowed CORS: origin, method, headers, every blocked response carries the CORS verdict headers, and a disallowed origin on a normal request simply gets no CORS headers (the browser enforces). The wildcard-origin plus corsAllowCredentials combination fails config construction.
$config = new SecurityConfig(
enableRedis: false,
enableCors: true,
corsAllowOrigins: ['https://app.example.com'],
corsAllowMethods: ['GET', 'POST'],
);
$engine = new GuardEngine($config);For pass-through (non-blocked) responses, adapters merge the per-request CORS map into their outgoing headers:
foreach ($engine->corsResponseHeaders($request) as $name => $value) {
$response = $response->withHeader($name, $value);
}By default the engine computes the reference security header set (port of
guard-core handlers/security_headers_handler.py): the ten class defaults
(X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN,
X-XSS-Protection: 1; mode=block,
Referrer-Policy: strict-origin-when-cross-origin,
Permissions-Policy: geolocation=(), microphone=(), camera=(),
X-Permitted-Cross-Domain-Policies: none, X-Download-Options: noopen, COEP/COOP/CORP
require-corp/same-origin/same-origin) plus
Strict-Transport-Security: max-age=31536000; includeSubDomains. Blocked
responses carry the headers engine-side (the fail-secure 500s included), and
blocked responses compose them with the CORS verdict headers when CORS is
enabled. For pass-through responses the adapter merges
GuardEngine::responseHeaders() with its outgoing headers. Setting
securityHeaders: ['enabled' => false] removes every security header.
$config = new SecurityConfig(
enableRedis: false,
securityHeaders: [
'enabled' => true,
'hsts' => ['max_age' => 31536000, 'include_subdomains' => true, 'preload' => false],
'csp' => ['default-src' => ["'self'"]],
'frame_options' => 'DENY', // null keeps the class default
'permissions_policy' => 'geolocation=(self)', // '' removes the header
'custom' => ['X-Request-Id' => 'trace'], // lands last, may override anything
],
);
$engine = new GuardEngine($config);
foreach ($engine->responseHeaders() as $name => $value) {
$response = $response->withHeader($name, $value);
}Attach behavior rules to a route (or globally with globalBehaviorRules):
usage/frequency rules count requests the pipeline allowed per (endpoint,
client) over a sliding window and dispatch ban/log/throttle/alert
when the count exceeds the threshold; return_pattern rules match outgoing
responses (status:<code>, json:<path>==<expected>, regex:<pattern>, or
a bare substring) and dispatch the same actions. Body-reading patterns
require behaviorScanResponseBody: true (they fail config construction
otherwise, mirroring the reference's fail-closed check) and read at most
behaviorMaxResponseBodyInspectBytes of the leading body.
$config = new SecurityConfig(
enableRedis: false,
behaviorScanResponseBody: true,
globalBehaviorRules: [
['rule_type' => 'return_pattern', 'threshold' => 5, 'window' => 60,
'pattern' => 'status:404', 'action' => 'ban', 'ban_duration' => 600],
],
);
$engine = new GuardEngine($config);
// Route-level rules (usage rules run automatically on allowed requests):
$engine->execute($request); // tracks routeConfig->behaviorRules usage/frequency
// Response side: adapters call this on every pass-through response:
$engine->processResponse($request, $response);RouteConfig carries the reference detection-exclusion surface: enableSuspiciousDetection (route-level kill switch or opt-in, winning over the global flag), excludedDetectionParams, excludedDetectionBodyFields and enabledDetectionCategories (a non-null route set replaces the global one, an empty category list disables every category), excludedDetectionHeaders (always merged on top of the defaults and the global set, suppressing ssrf address-chain false positives only) and detectionScanBody (a false skips the request-body surface only).
$route = new RouteConfig(
enableSuspiciousDetection: true, // route-level kill switch / opt-in
excludedDetectionParams: ['search_hint'], // replaces the global param set
enabledDetectionCategories: ['sqli'], // narrows the category set
detectionScanBody: false, // skips the body surface only
);
$request->state()->routeConfig = $route;A family of detection patterns anchored at \A walks the subject one character
(or one path segment) at a time: the etc/passwd, boot.ini, proc/self/environ
and var/log line walks, the keyword-lookahead double walks, and the anchored
path-walk segment loops for .htaccess, wp-admin, .env, .git, recon path
targets and siblings (PatternData::SIZE_GATED_PATTERN_INDICES). PCRE2 consumes
stack proportional to the walked line, so on stock php:8.3 ini a benign
single-line subject can exhaust PCRE2 and abort detection entirely:
pcre.jit=1(default): failures from ~24.5KB subjects (PREG_JIT_STACKLIMIT_ERROR) for the line-walk shapes, and from ~16.4KB for the segment-loop shapes once a trailing target follows ~16KB of path segments (a/repeated is the stack-densest input, floor cliff ~16392 bytes).pcre.jit=0: failures from ~100KB subjects (PREG_RECURSION_LIMIT_ERROR).
Mitigation: when a view subject's first line reaches
SusPatterns::GATED_PATTERN_MAX_SUBJECT_BYTES (15360, 15 KiB), the preg calls
of that family are skipped for the rest of the scan (no match contribution) and
detection completes normally. The threshold sits below the ~16.4KB segment-loop
cliff and above the largest conformance corpus content (14725 bytes), so it
holds under either pcre.jit setting (ini-independent) and never changes
conformance behavior.
Coverage trade-off: walk and segment patterns are line-scoped, so skipping above the gate only forgoes their coverage on very long single-line subjects (15KB or more in one line). All other patterns still scan the full subject, and probes in shorter lines or multiline bodies are unaffected.