Detection Tuning¶
The detection engine's behavior is controlled by several SecurityConfig fields prefixed with detection_. This guide explains each field and how to tune them for different deployment scenarios.
Configuration Fields¶
detection_compiler_timeout¶
Type: float | Default: 2.0 | Range: 0.1 - 10.0
Maximum time in seconds for a single regex pattern match. Patterns that exceed this timeout are cancelled, preventing ReDoS attacks from consuming server resources.
| Value | Tradeoff |
|---|---|
0.5 |
Very aggressive. May cause false negatives on complex inputs. |
2.0 |
Balanced. Catches most attacks while limiting resource usage. |
5.0 |
Permissive. Better detection but higher latency risk. |
detection_max_content_length¶
Type: int | Default: 10000 | Range: 1000 - 100000
Maximum character count for content passed to the detection engine. Content exceeding this limit is truncated (with attack-preserving logic if enabled).
| Value | Tradeoff |
|---|---|
3000 |
Fast processing. May miss attacks in large request bodies. |
10000 |
Balanced for most APIs. |
50000 |
Thorough scanning. Higher memory and CPU usage per request. |
detection_max_body_inspect_bytes¶
Type: int | Default: 262144 | Range: 1024 - 10485760
Maximum bytes read from the start of the request body and inspected during detection. Bodies whose Content-Length exceeds this are never read or scanned, bounding memory on the detection hot path.
This is a memory bound, not full-body coverage. Only the first detection_max_body_inspect_bytes bytes of the body are ever scanned, whether they come from a Content-Length-bounded read or from an adapter's BoundedBodyReader.read_body_prefix. An attacker who pads a request with that many bytes of filler before the actual payload, or splits a signature across the boundary, evades detection; this is inherent to bounded-memory scanning and cannot be closed without reading the whole body. Raise the value to shrink the blind spot at the cost of holding more memory per inspected request; it is a tradeoff, not a full-scan guarantee.
Distinct from detection_max_content_length (the regex scan window over already-decoded content) and max_request_size (the request-size gate that returns a 413).
body_read_timeout¶
Type: float | Default: 3.0 | Range: 0.0 (exclusive) - 30.0 | Scope: both trees
Seconds to wait for an adapter's read_body_prefix/body call before treating the body as unavailable to detection. In guard_core (async) this bounds the wait via asyncio.wait_for, against a stalled or misbehaving adapter/stream.
In guard_core.sync, a blocking call cannot be cancelled from the outside, so each read attempt runs on its own daemon thread and body_read_timeout bounds how long the caller joins that thread; the thread itself keeps running in the background until the adapter's call returns, only the caller stops waiting for it. sync_body_read_max_concurrent (default 64) caps how many such threads may be blocked at once; once that budget is exhausted, further attempts queue for it and give up (logging the exhaustion) rather than spawning without limit.
detection_preserve_attack_patterns¶
Type: bool | Default: True
When True, the truncation algorithm identifies attack-like regions in the content and preserves them in the truncated output, even if they fall beyond the max_content_length boundary. Set to False for simple left-truncation when performance is critical.
detection_semantic_threshold¶
Type: float | Default: 0.7 | Range: 0.0 - 1.0
Minimum score from the SemanticAnalyzer to classify content as a threat. The semantic analyzer scores content across multiple attack types (XSS, SQL injection, command injection, path traversal, template injection).
| Value | Tradeoff |
|---|---|
0.3 |
Very sensitive. High detection rate but more false positives. |
0.7 |
Balanced. Good detection with low false positive rate. |
0.9 |
Conservative. Only high-confidence semantic threats trigger. |
Semantic vs Regex
Regex patterns provide definitive threat detection. Semantic analysis is a secondary layer that catches obfuscated or novel attacks. Lowering the semantic threshold increases the chance of catching evasion attempts but also increases false positives on legitimate content containing technical terms.
detection_anomaly_threshold¶
Type: float | Default: 3.0 | Range: 1.0 - 10.0
Number of standard deviations slower than the mean execution time to flag a pattern as anomalous; a faster-than-average execution is never flagged. This tracks performance anomalies, not security threats. Anomaly events sent to the agent handler are additionally rate-limited per pattern by PerformanceMonitor's anomaly_emission_cooldown (default 60s), so a host-wide stall cannot burst thousands of events at once, see docs/internals/detection-engine.md.
| Value | Tradeoff |
|---|---|
2.0 |
Sensitive anomaly detection. More alerts on normal variance. |
3.0 |
Standard. Catches significant deviations. |
5.0 |
Only extreme outliers trigger alerts. |
detection_slow_pattern_threshold¶
Type: float | Default: 0.1 | Range: 0.01 - 1.0
Execution time in seconds above which a pattern is considered slow. Slow patterns are reported in performance diagnostics and may indicate ReDoS vulnerability.
detection_monitor_history_size¶
Type: int | Default: 1000 | Range: 100 - 10000
Number of recent performance metrics retained in the PerformanceMonitor. Larger values provide better statistical analysis but consume more memory.
detection_max_tracked_patterns¶
Type: int | Default: 1000 | Range: 100 - 5000
Maximum number of unique patterns tracked by the performance monitor. When exceeded, the oldest pattern's stats are evicted. Also controls the PatternCompiler cache size.
detection_threat_score_threshold¶
Type: float | Default: 1.0 | Range: 0.0 - 10.0
Anomaly/threat score required to flag a request as a threat.
detection_scan_body¶
Type: bool | Default: True
Whether to scan the request body during detection. Set to False to restrict detection to the URL path, query parameters, and headers: the body is then never read or matched, regardless of its shape.
Tuning Profiles¶
High Security¶
For applications handling sensitive data where false negatives are unacceptable:
SecurityConfig(
detection_compiler_timeout=5.0,
detection_max_content_length=50000,
detection_preserve_attack_patterns=True,
detection_semantic_threshold=0.3,
detection_anomaly_threshold=2.0,
detection_slow_pattern_threshold=0.05,
)
Balanced (Default)¶
Suitable for most production deployments:
SecurityConfig(
detection_compiler_timeout=2.0,
detection_max_content_length=10000,
detection_preserve_attack_patterns=True,
detection_semantic_threshold=0.7,
detection_anomaly_threshold=3.0,
detection_slow_pattern_threshold=0.1,
)
High Performance¶
For high-throughput APIs where latency is critical:
SecurityConfig(
detection_compiler_timeout=0.5,
detection_max_content_length=3000,
detection_preserve_attack_patterns=False,
detection_semantic_threshold=0.9,
detection_anomaly_threshold=5.0,
detection_slow_pattern_threshold=0.05,
detection_monitor_history_size=100,
detection_max_tracked_patterns=200,
)
Detection Disabled¶
For routes where detection is not needed (e.g., health checks):
Or per-route via decorators:
Diagnostics¶
The SusPatternsManager provides runtime diagnostics:
from guard_core.handlers.suspatterns_handler import sus_patterns_handler
stats = await sus_patterns_handler.get_performance_stats()
# {
# "summary": {"total_executions": 15432, "avg_execution_time": 0.003, ...},
# "slow_patterns": [...],
# "problematic_patterns": [...]
# }
status = await sus_patterns_handler.get_component_status()
# {"compiler": True, "preprocessor": True, "semantic_analyzer": True, "performance_monitor": True}
Use these diagnostics to identify patterns that need optimization or replacement.
Binary uploads and the artifact density gate¶
Text-decoded binary bodies (multipart file uploads, zip archives) used to produce spurious pattern matches on artifact bytes, so binary uploads were blocked and their client IPs auto-banned. The sus-pattern engine now discards a regex match that is about to become a threat when the match comes from a registered noise-prone heuristic pattern (the shell-source family: glued backtick pairs, dollar substitutions, the shell keyword chain, quote splice, glob wildcard atoms, template fragments, LDAP paren conjunctions; the registry is NOISE_PRONE_PATTERN_SOURCES in the pattern table) and the scanned string is dense in binary artifact characters: control characters (except tab, newline, carriage return, plus DEL), Latin-1/Latin-Ext-A artifact bytes outside a small text allowlist, surrogateescape bytes and the Unicode replacement character, with at least 4 such characters inside the 64-character margin around the match. Signature patterns are deliberately never gated, so padded-payload recall (webshells, pickle opcodes, base64-fragmented parts) is unchanged. The check is an O(1) prefix-sum difference per match, built once per scanned string, and is invisible on pure text: a string with zero artifacts passes every match through unchanged, so accented European text, Cyrillic, CJK and every other non-Latin script keep full detection coverage. See specs/06-suspatterns.md ("Binary artifact density gate") for the exact definition; derived threats without a match position (timeouts, decode-budget exhaustion, semantic and JSON structural threats) are not filtered by this gate.
Printable-run islands for binary file parts¶
The density gate suppresses noise-prone pattern matches near artifact bytes, but a non-gated pattern can still fire inside a compressed file part: in half a megabyte of deflate output the probability of a short attack-shaped printable fragment (for example the LDAP )( followed by !) reaches certainty, and a fragment that lands in a locally clean 64-character window is not gated. Instead of extending that whack-a-mole to every pattern, the scan input for the part is reduced before pattern matching ever runs. A multipart file-part payload whose binary artifact characters (the same set the density gate counts, via build_binary_prefix) make up at least a fifth of it is binary-dense and is reduced to its printable runs (extract_binary_islands in guard_core/detection_engine/binary_islands.py): runs of tab, newline, carriage return, ASCII 0x20-0x7E, and decoded non-ASCII printable characters, keeping only runs whose length reaches detection_binary_min_run_length (default 16) and scanning each retained run as its own scan value, so no pattern can match across the binary bytes that separate two runs, and no pair of sub-threshold matches can accumulate into a block across that gap. A part payload below the ratio (text uploads, small files, mostly-text documents) keeps its full scan, text parts without a filename are never reduced, and whole-body fallback scans (_scan_blob_body) are never reduced at all: raw-body signature coverage (pickle opcodes at scan-window boundaries, UTF-16/32 payloads routed through the wide-encoding preprocessor, null-byte LDAP shapes) is a deliberate contract the density gate already preserves, and content-level reduction would break it. Each retained run costs one scan value against detection_max_scan_values and its length against detection_max_scan_chars, so the existing budgets bound a payload that decomposes into many runs.
The effect on compressed data is that the printable runs it produces are almost never 16 characters long, so a large archive yields no scannable content at all and the false-positive rate stops growing with file size. Text genuinely embedded in a binary-dense file survives: a script inside a PDF's text sections, a stored path inside an archive, a padded webshell's code run, all form one long run that is scanned in full, and file names, multipart field names, and part headers are scanned regardless. Two things are given up, deliberately, and only for binary-dense part payloads: an attack pattern whose printable characters are shorter than the configured run length is not detected, and a pattern whose match would span embedded binary bytes (for example the null-byte LDAP shapes inside a file part) is not detected either; both remain detected in text payloads, which keep their full scan. Raising the threshold trades recall for fewer noise matches; lowering it toward 4 restores short-fragment detection at the cost of reintroducing statistical matches on large binary uploads.
Known Limitations¶
- NoSQL operator detection. Numeric range operators (
$gt,$gte,$lt,$ltewith a numeric literal) are not flagged as NoSQL injection: they are indistinguishable from legitimate range queries. Auth-bypass shapes ($nenull or boolean,$gt "",$regex,$where,$exists,$in) are flagged. Use schema validation or route-level allowlisting for numeric fields. - SSRF via attacker-controlled DNS. Any hostname can resolve to an internal IP at request time, and no request-body pattern can see a DNS answer. Use an egress-time resolved-IP check (DNS-rebinding-aware) for fields that accept arbitrary hostnames, not pattern matching alone.
- Command execution aliases. Node's
execFile/execFileSync, Ruby'sKernel#spawnand backtick literals, Perl's list-formsystem, Go'sos/exec.Command, PowerShell'sInvoke-Expression, and any project-local wrapper function around any of these are not covered. Use a language-aware static analyzer or a runtime sandbox for code paths that execute external processes. - Python dynamic dispatch.
os.__dict__ ['system'](...),globals() ['os'].system(...),operator.attrgetter(...), and chainedimportlibindirection are not covered. Avoid resolving dangerous stdlib callables from request-controlled strings; use an explicit allowlist of callable names if dynamic dispatch is required. - Dynamic code execution. String-concatenated property access such as
window['ev'+'al'], an alias bound earlier such asvar x = eval; x(...), and a non-literal argument such asFunction(atob(encoded))are not covered. Use a Content-Security-Policy that disallowsunsafe-evalas the enforcement layer; pattern matching cannot resolve an expression it does not evaluate. - Custom pattern validation timeout. A custom pattern registered through
add_pattern(directly, restored from Redis, or pushed by a dynamic rule) whose honest reach-probe measurement needs more than the shared wall-clock budget is refused with the timeout reason rather than accepted; simplify the pattern to bring it under that budget. When a pattern's probe enumeration alone would exceed that budget, a deterministic stride sample of the probe sets (every probe family and repeat site represented) is timed instead of the full enumeration. The budget is 40 seconds on an unloaded host and scales by the same measured load factor that normalizes the sample times (ceiling 240 seconds), so a verdict does not depend on the machine or its load. - Nested unbounded quantifiers are refused before timing. A custom pattern whose group carries an unbounded quantifier and is itself unboundedly quantified (the
(x+)+shape) is rejected with the dangerous-construct reason without a timed probe, even when a mandatory separator inside the group makes it linear;^(?:/[^/]+)+/?$is refused although it backtracks linearly. Rewrite such a pattern so the repeated group starts with its separator and the first segment stands alone, for example^/[^/]+(?:/[^/]+)*/?$. This is deliberate: exponential triggers are short and a probe can miss one, so a false rejection is preferred over a false acceptance.
Request Contexts per Category¶
Every pattern category runs only on the request contexts it is scoped to. A payload in a context outside the category's set is never matched, whatever the pattern. The sets, as shipped:
| Category | Contexts |
|---|---|
cmd_injection |
header, query_param, request_body (the glued-shell substitution patterns also run on url_path) |
cms_probing |
query_param, request_body, url_path |
code_injection |
header, query_param, request_body, url_path |
deserialization |
header, query_param, request_body, url_path |
dir_traversal |
header, query_param, request_body, url_path |
file_inclusion |
header, query_param, request_body, url_path |
file_upload |
header, query_param, request_body |
http_split |
header, query_param, request_body, url_path |
ldap |
header, query_param, request_body, url_path |
nosql |
header, query_param, request_body, url_path |
path_traversal |
header, query_param, request_body, url_path |
proto_pollution |
header, query_param, request_body, url_path |
recon |
query_param, request_body, url_path |
sensitive_file |
query_param, request_body, url_path |
sqli |
header, query_param, request_body, url_path (three noise-prone rows, the bare ORDER BY n terminator, the glued comment and the bare EXEC sp_/xp_, stay on query_param and request_body; a quote- or digit-prefixed ORDER BY n and a ;- or quote-prefixed EXEC run everywhere) |
ssrf |
header, query_param, request_body, url_path |
template |
header, query_param, request_body, url_path |
xml |
header, query_param, request_body, url_path |
xss |
header, query_param, request_body, url_path |
Every category also runs on the unknown context, which is what a direct SusPatternsManager.detect() call without a context uses. Multipart parts, form fields and JSON values scan as request_body; a cookie value scans as header; a matrix parameter or path segment scans as url_path. 4.0.0 reviewed the whole matrix pair by pair with the per-context benchmark: nineteen (category, context) pairs that were never scanned gained coverage where the malicious corpus fired and the benign corpus stayed clean (three of them extend an already-disclosed false-positive class into one more context, with the corpus ids named in the benchmark), sqli gained header and url_path with three noise-prone rows kept narrow at pattern level, and every pair still excluded (cmd_injection on paths beyond the substitution patterns, file_upload on paths, cms_probing, recon and sensitive_file on headers) is one where the malicious corpus gains nothing on that context. That benchmark (tests/test_sus_patterns/test_detection_benchmark.py, run by the Detection Gate workflow) now measures recall and false positives per context for every category, so a change to any set shows up as a number, not a surprise.