gwaf

package module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 15, 2026 License: MIT Imports: 16 Imported by: 0

README

gwaf

An embeddable, Go-native Web Application Firewall library.

Every other WAF is an interpreter. gwaf is a compiler.

Rules, schemas, and route policies are inputs to an optimizer that emits a specialized, pointer-free execution plan. A literal prefilter decides what to evaluate before any rule runs, transform chains are computed once and shared by every rule that needs them, ambiguous input is evaluated every plausible way rather than guessed at, and work is metered in deterministic fuel rather than wall-clock.

Status: M1 — compiler core. The engine works and is tested; see docs/PLAN.md for what is built and what is next.

// A working, blocking firewall with no configuration at all.
waf, _ := gwaf.New()

// Tell it which hostnames are yours and it also catches open redirects and
// SSRF — it cannot judge "somewhere else" without knowing where here is, and
// the request's own Host header is attacker-supplied.
waf, _ = gwaf.New(gwaf.WithOrigins("api.example.com"))

mux := http.NewServeMux()
mux.HandleFunc("GET /api/orders", handleOrders)

http.ListenAndServe(":8080", middleware.HTTP(waf)(mux))

That is the whole integration. See examples/basic for one with an API schema and decision logging, and INTEGRATION.md for the transaction API when you are embedding into something that is not net/http.

Measured, not claimed

darwin/arm64, Go 1.26.5, full core ruleset (66 rules), 200,000 samples per workload. Reproduce with make bench-publish.

Workload p50 p99 Allocations
Benign GET, no body 917 ns 1.29 µs 0
Benign POST, 1 KiB JSON 17.0 µs 24.0 µs 0
Attack (blocked at header phase) 708 ns 958 ns 0

Percentiles rather than means, because a mean hides the request that took forty times longer — and that request is the one an attacker is trying to produce.

Request bodies are decompressed first (gzip, deflate, zlib — stdlib only), and an encoding gwaf cannot decode is reported rather than passed through: a compressed body inspected as-is is opaque, which makes one header enough to disable the whole firewall.

Detectors only ever see text. JSON, form, and multipart bodies are parsed into fields; binary content has its printable runs extracted; base64 is decoded first, because the origin decodes it too.

That is a performance decision and a correctness one. Reading encoded binary as prose cost 20 M fuel for one 700 KiB upload and found nothing. And inspecting raw bytes misses what the application actually receives: a \u003cscript\u003e escape is inert on the wire and <script> after JSON parsing, and every multipart part is inspected — checking only the final one is precisely CVE-2026-21876.

Detection, on a corpus of real bypass techniques:

Evasion corpus 271/271 blocked (100%) across 31 attack classes
False-positive corpus 0/124 (0.00%)
Calibration corpus 10,473 benign requests, every rule matches none of them
Pentest suite 189/189 blocked, 0/62 false positives; sqlmap finds no injection

The evasion corpus is organised as attack class × evasion technique, and a class gwaf claims to detect with too few cases behind the claim fails the build. That check exists because it was needed: a technique-only corpus once reported 76/76 while template, NoSQL, and LDAP injection each scored 0/0 — and 0/0 does not appear in a percentage. The same blind spot reappeared later one level up, when the class list was the thing with the hole.

SQL injection and XSS are detected structurally, by grammar rather than by signature.

1'/*!50000OR*/1=1--, 1' XOR 1=1--, <svg/onload=alert(1)>, java\tscript:, and x" onerror="alert(1) are all caught with no rule written for any of them. Meanwhile "the union selected a new representative" and "the onerror callback fires when loading fails" are not — the keywords are present but the grammar is not. Both of those were false positives under the literal rules this replaced.

Seven literal rules were deleted and two structural detectors added. Detection did not drop and two real false positives went away.

What is covered, beyond SQL injection and XSS: command, template, NoSQL, LDAP, and expression-language injection; path traversal and local/remote file inclusion; XXE; Log4Shell including the ${${lower:j}ndi: nesting no substring of jndi survives; Spring4Shell; PHP object injection and Java deserialization; SSRF against cloud metadata; prototype pollution; GraphQL depth, complexity, and alias amplification; gRPC and protobuf payloads; and file upload in both halves — the web shell going in, and the request that would execute one already on disk.

The evasion corpus covers case variation, whitespace splitting, single and double percent-encoding, overlong UTF-8, NUL truncation, backslash separators, HTML entities, and UTF-7 (CVE-2026-21876) — plus combinations, and payloads delivered via headers and bodies. Detection rate is never reported without the false-positive rate beside it.

Schema as a compiler input — the flagship:

With schema Without
Latency 950 ns 1.58 µs
Work performed (fuel) 185 610

40% faster, 70% less work, and stricter — every out-of-spec request rejected before a rule runs. A field declared an integer that validates as one cannot contain UNION SELECT, so those rules are skipped soundly rather than heuristically. Specifying your API makes gwaf both faster and safer.

Ruleset scaling — the central claim:

Rules Latency Rules evaluated
10 233 ns 0
100 234 ns 0
1,000 233 ns 0
10,000 233 ns 0

A thousand-fold larger ruleset costs the same. Rules evaluated per request is a small constant independent of ruleset size — zero for values containing no attack vocabulary, and bounded above by a handful otherwise. Enforced as tests (TestSLO*), not merely observed in benchmarks.

Against Coraza + CRS, on somebody else's corpus

Detection numbers are easy to publish and hard to trust, so this one is a comparison, on a corpus neither engine's authors wrote, run through both engines the way an adopter deploys them — as ordinary net/http middleware in front of the same origin.

2,457 payload-bearing exploit requests extracted from projectdiscovery/nuclei-templates — real requests for real CVEs — against Coraza v3.7.0 + CRS v4.25.0:

gwaf gwaf tuned Coraza + CRS 4.25
Detection 89.3% 93.1% 89.7%
False positives (ordinary traffic) 2/12 0/12 4/12
Latency 61 µs 57 µs 920 µs

Ahead on all three columns — detection, false positives, and roughly a sixteenth of the latency. CRS leads on RCE alone now; XSS and SQLi are ties at 993/1007 and 69/83; gwaf leads on redirect (55/60 against 3/60), SSRF (44/63 against 14/63), LFI (647/674 against 643), file upload and SSTI, and wins outright on encoded payloads — 100% against 85.4% on a corpus of the same attacks re-encoded eight ways.

The detection margin is thin and the false-positive one is not. An engine that blocks four legitimate requests in twelve can always find another point of recall; the column to read is the pair.

tuned is the shape an adopter deploys rather than a flattering one: a platform profile, the opt-in rules with their body-phase counterparts, and one scoped exception for a webhook route that takes third-party URLs by design. All of it is in the test, and the exception is there because without it the fetch rule correctly blocks webhook registration — which is exactly why that rule ships opt-in.

Run it yourself:

git clone --depth 1 https://github.com/projectdiscovery/nuclei-templates /tmp/nuclei-templates
git clone --depth 1 https://github.com/coreruleset/coreruleset /tmp/crs
python3 test/headtohead/extract_corpus.py /tmp/nuclei-templates/http > /tmp/corpus.json
NUCLEI_CORPUS=/tmp/corpus.json CRS_RULES=/tmp/crs/rules make nuclei

Two things about that harness are worth knowing, because both produced flattering nonsense before they were found. A missing Host header makes CRS score rule 920280 at its entire anomaly threshold, so every request blocks for a reason unrelated to its payload — the first version of this comparison reported 100% detection beside a 100% false-positive rate. And LF-normalised multipart bodies are refused by every parser, so uploads become invisible. Roughly 56% of the corpus is also version probes carrying no payload; counting those as misses understates both engines by about twenty points and distinguishes neither, so they are separated rather than scored.

test/headtohead also runs the CRS regression suite, which is CRS's home turf. Both are there deliberately: quoting either alone is picking the ground.

What makes it different

Fast 0 rules evaluated and 0 allocations on benign traffic; flat in ruleset size
Accurate Structural SQL detection — grammar, not signatures. One implementation covers the variant family a signature list enumerates one payload at a time.
Secure Ambiguous input is evaluated every plausible way, not guessed at; provable DoS bound via fuel metering
Embeddable Zero CGO, zero dependencies, zero global state, no daemon, no UI
Compounding Specifying your API schema makes gwaf both faster and more precise

Documentation

Doc What
CONCEPT.md The architectural thesis — 12 core concepts. Start here.
COMPARISON.md gwaf vs Coraza, CrowdSec, SafeLine, open-appsec, Sophos — including where gwaf is behind
PLAN.md Execution plan, milestones, gates, kill criteria
ROADMAP.md Phase detail and risk register
RULES.md Rule authoring, extension interfaces, confidence & policies
INTEGRATION.md Three integration profiles with code
PERFORMANCE.md How the SLOs are reached, and what's forbidden
GATEON-MIGRATION.md First adopter: replacing Coraza
CLAUDE.md Project guidelines, structure, standards

Protecting an app that is not written in Go

A library protects only the process that imports it. proxy/ is the reference reverse proxy that puts gwaf in front of anything else — PHP, Node, Python, a WordPress install:

go build -o gwaf-proxy ./proxy
./gwaf-proxy -upstream http://127.0.0.1:8080 -listen :80

./gwaf-proxy -upstream http://127.0.0.1:8080 -detect-only -v   # measure first

It is pure glue, ~325 lines, capped at ~500, with no detection logic, no rules of its own, and no config file it discovers (CLAUDE.md §1 tier 3). If it ever needs a feature, the library is missing an API and the fix goes there. See proxy/README.md.

Positive security: what no signature can catch

A signature answers "does this value look like an attack?". Some of the most expensive attacks do not look like anything. A stake of -5000 is a valid number, "BTC" is a valid currency string, and /phpmyadmin/index.php is a valid path — they are attacks only because your application does not accept them, and only your application can say so.

api, _ := schema.New(schema.Operation{
    Method: "POST", Path: "/api/v1/bets", Strict: true,
    Body: []schema.Field{
        {Name: "event_id", Kind: schema.KindString,
            Format: schema.FormatUUID, Required: true},
        {Name: "stake", Kind: schema.KindNumber, Required: true,
            Min: schema.Bound(0.01), Max: schema.Bound(10_000)},
        {Name: "currency", Kind: schema.KindEnum,
            Enum: []string{"USD", "EUR", "GBP"}},
    },
})
api.Closed()   // anything matching no operation is rejected

waf, _ := gwaf.New(gwaf.WithSchema(api))

That rejects a negative stake, an integer-overflow payout, an unsupported currency, a smuggled is_admin field, a missing required field — and every reconnaissance probe for a route this API does not have, without naming a single product. examples/positivesecurity runs all of it and is a test, so the claims stay true.

Read Schema.Closed before enabling it: a closed schema is only correct once the schema is complete.

Observability

gwaf writes nothing anywhere — it returns a Decision and the embedder owns what happens next. What it owes is the shape, and two packages provide it with zero dependencies:

rec := audit.NewRecord(d, audit.Context{Method: r.Method, Path: r.URL.Path}, time.Now())
sink.Write(rec)          // line-delimited JSON, fan-out, severity filtering

metrics.Observe(d, elapsed)
snap := metrics.Snapshot()   // blocked/allowed, per-rule counts, TopRules, latency

An audit record carries the matched bytes, the transform chain, and the narrowest exception that would suppress the finding — so a false positive is a scoped fix rather than a rule somebody disables wholesale. OpenTelemetry is deliberately absent: an exporter is a dependency you did not choose, and Sink is one method so wiring your own is small.

Shadow-API discovery

You cannot protect endpoints you do not know about. gwaf reports that a request went somewhere the schema does not describe; you keep the inventory, because aggregating is memory and memory is the embedder's:

middleware.OnUndeclaredRoute(func(r *http.Request) {
    inventory.Observe(r.Method, r.URL.Path)
})

It reports in both open and closed schemas, so discovery does not stop the moment enforcement starts.

Optional rules

Everything in the core ruleset is Certain or High confidence, so gwaf.New() blocks without a tuning phase. Rules that are right for most deployments but not all ship exported instead of enabled, with the trade documented at the point of use:

Rule Why it is opt-in
core.WordPressHardeningRule blocks all direct PHP under wp-content; a minority of plugins expose endpoints that way
core.SSRFParamRule handing a server a foreign URL is what webhook, feed and avatar import are
core.LoopbackSSRFRule localhost and 127.0.0.1 are ordinary in CI, staging, and webhook targets
core.CRLFHeaderRule needs a transform chain no other rule shares — measured at 8% of the latency budget
graphql.IntrospectionRule introspection is how every GraphQL development tool discovers a schema
waf, _ := gwaf.New(gwaf.WithRuleset(core.WithBodyPhase(
    rules.Set{core.WordPressHardeningRule(1011), core.SSRFParamRule(1016)})))

WithRuleset accumulates onto the default set — pass only the extra rules.

core.WithBodyPhase is not decoration. The core rules get a request-body counterpart generated for them; a rule you add does not, so an argument rule declared at the header phase sees the query string and never a JSON body. When that matters and you left it out, waf.Diagnostics() says so by name — as it does for the off-origin rules when no origins are declared:

for _, d := range waf.Diagnostics() {
    log.Warn("coverage gap", "rule", d.ID, "reason", d.Reason, "fix", d.Fix)
}

Methodology, hardware, re-run instructions, and what the numbers do not show: docs/BENCHMARKS.md. One command reproduces them:

make bench-publish

Framework integration

waf, _ := gwaf.New()                       // blocking, core ruleset, no config

http.Handle("/", middleware.HTTP(waf)(mux))          // net/http, chi, gorilla
r.Use(gwafgin.Middleware(waf))                       // gin
e.Use(gwafecho.Middleware(waf))                      // echo
app.Use(gwaffiber.Middleware(waf))                   // fiber

chi, gorilla/mux, connect-go, and the standard library need no adapter: middleware.HTTP is already a func(http.Handler) http.Handler.

Everything beyond core lives in its own module, so importing gwaf pulls in nothing you did not ask for — the core module has zero third-party dependencies, and that is the one property no competing WAF library offers.

Module Why it is separate
middleware so a framework adapter never reaches core
adapters/{gin,echo,fiber} your router is your choice, not gwaf's
schema/openapi YAML needs a parser core will not carry
schema/grpc protobuf descriptors need google.golang.org/protobuf
seclang CRS migration links a regex engine
proxy the reference reverse proxy; glue, not library

staticcheck and govulncheck run over every module in make check, not just core — core is the one module that cannot have a dependency CVE, so scanning it alone would scan the wrong place.

Writing your own rules

Rules are Go values, so they diff, code-review, and fail the build on a typo rather than silently never firing:

rules.Rule{
    ID:         1_000_001,
    Phase:      types.PhaseRequestHeaders,
    Targets:    []types.Target{{Kind: types.TargetRequestPath}},
    Transforms: []rules.Transform{transform.Lowercase, transform.NormalizePath},
    Op:         op.HasPrefix("/internal/"),
    Actions:    []rules.Action{rules.Block},
    Severity:   types.SeverityCritical,
    Confidence: types.Certain,
    Msg:        "internal-only path reached from outside",
}

examples/customrules walks the whole surface in one runnable program — built-in operators, op.Func and what a literal hint buys back, a custom Operator, Transform, Action, and Resolver, and an Exception for the day one of your rules is wrong about one route:

go run ./customrules

It is a test as well as an example, so what its comments claim is what CI checks. Reference: docs/RULES.md.

Scope

gwaf analyzes one request in isolation, with no memory, and answers one question: is this an attack? Anything needing state, connection ownership, privilege, or policy belongs to the embedder — rate limiting, reputation, bot scoring, eBPF, and the decision of what to do about a finding.

gwaf never buffers. Holding a response breaks streaming and time-to-first-byte, and only whoever owns the connection can weigh that. Feed it what you choose; feed it nothing and it says so rather than calling the response clean.

// Response inspection: what leaves, not what arrives.
tx.SetResponseStatus(200)
tx.AddResponseHeader("Content-Type", "application/json")
if d := tx.ProcessResponseHeaders(); d.Blocked() {
    return   // before the first byte — the only moment it can be stopped
}
tx.WriteResponseBody(chunk)      // as many times as you like
d := tx.ProcessResponseBody()

slog.Warn("blocked", "waf", d)   // Decision implements slog.LogValuer

Ownership is decided by five tests in CLAUDE.md. The last one — needs a dependency the embedder did not choose — is why SecLang, OpenAPI-YAML, brotli, and framework adapters live in their own modules, and why core carries zero third-party dependencies.

License

Apache-2.0

Documentation

Overview

Package gwaf is an embeddable, Go-native web application firewall.

gwaf is a library. It is imported into an application, runs on that application's goroutines, and holds no global state. There is no daemon, no admin server, and no user interface: gwaf decides whether a request is an attack and reports why, and the embedder decides what to do about it.

Getting started

New with no options returns a working, blocking firewall with the first-party ruleset loaded:

waf, err := gwaf.New()
if err != nil {
	return err
}

tx := waf.NewTransaction()
defer tx.Close()

tx.SetRequestLine(r.Method, r.RequestURI, r.Proto)
tx.SetRemoteAddr(r.RemoteAddr)
for name, values := range r.Header {
	for _, v := range values {
		tx.AddRequestHeader(name, v)
	}
}

if d := tx.ProcessRequestHeaders(); d.Blocked() {
	http.Error(w, "forbidden", d.Status())
	return
}

Blocking at the header phase means the body is never read from the client, never parsed, and never transformed.

How it works

A conventional WAF walks its ruleset per request: for every rule, resolve its targets, transform each value, run its operator. That is O(rules × values) transform-and-match operations, and it is why WAF latency scales with ruleset size.

gwaf compiles instead. Rules, their transform chains, and their required literals are inputs to a compiler that emits an execution plan: rules are grouped by transform chain, each chain's literals are compiled into one Aho-Corasick automaton, and at request time each value is normalized once per chain and scanned once. Only rules whose literals actually appeared are evaluated. On benign traffic the candidate set is empty and no operator runs at all — a ruleset of ten rules and one of ten thousand cost the same.

Guarantees

  • Benign traffic evaluates zero rules and performs zero allocations. Both are asserted by tests, not just measured by benchmarks.
  • Work is metered in deterministic fuel rather than wall-clock time, so the denial-of-service bound is provable and a budget violation reproduces in a unit test.
  • Every decision is explainable: it carries the rule, the matched byte span, and the score that produced it.
  • Rules evaluate in (phase, ID) order regardless of how the ruleset was assembled, so the same request always produces the same decision.

Scope

gwaf analyses one request in isolation, with no memory of any other. Anything requiring state, identity, time, or infrastructure — rate limiting, IP reputation, bot scoring, packet filtering — belongs to the embedder and reaches gwaf as an input rather than being maintained by it.

Concurrency

A WAF is safe for concurrent use by any number of goroutines. A Transaction is not: each is owned by exactly one goroutine for its lifetime. This is the most common misuse of every WAF library.

Any number of independent WAF instances may coexist in one process with different rulesets, which is what makes multi-tenant embedding and parallel tests work.

Rules

The first-party ruleset in ruleset/core contains only Certain and High confidence rules, which is what makes blocking by default defensible. Rules are plain struct literals:

rules.Rule{
	ID:         1_000_001,
	Phase:      types.PhaseRequestHeaders,
	Targets:    []types.Target{{Kind: types.TargetRequestHeaders, Name: "User-Agent"}},
	Transforms: []rules.Transform{transform.Lowercase},
	Op:         op.ContainsAny("sqlmap", "nikto"),
	Actions:    []rules.Action{rules.Block},
	Severity:   types.SeverityCritical,
	Confidence: types.Certain,
	Msg:        "Known vulnerability scanner",
}

Confidence is not an opinion about a rule; it is a measured property, and the tier a rule declares bounds the false-positive rate it is allowed to exhibit against the benign corpus.

Five interfaces are the extension surface: rules.Operator, rules.Transform, rules.Action, and — once implemented — Resolver and Detector. Custom operators that can honestly declare their required literals are prefiltered exactly like built-in ones; those that cannot are reported as unconditional at compile time, so their cost is visible before deployment rather than after.

See the docs directory for the architecture (CONCEPT.md), rule authoring (RULES.md), integration profiles (INTEGRATION.md), and the performance model (PERFORMANCE.md).

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Decision

type Decision struct {
	// contains filtered or unexported fields
}

Decision is the outcome of evaluating a phase.

It is a value type rather than a nillable pointer: callers write `if d.Blocked()` with no nil check and no type assertion. Every decision is explainable — it carries the rule that caused it, the matched bytes, and the score that produced it, because a block nobody can explain is a block that gets disabled.

func (Decision) Allowed

func (d Decision) Allowed() bool

Allowed reports whether the request may proceed.

func (Decision) Blocked

func (d Decision) Blocked() bool

Blocked reports whether the request should be rejected.

func (Decision) Confidence

func (d Decision) Confidence() types.Confidence

Confidence returns the responsible rule's confidence tier.

func (Decision) Detail

func (d Decision) Detail() string

Detail returns extra context for decisions that have no responsible rule, such as why the input was undecidable. It is empty otherwise.

func (Decision) Explain

func (d Decision) Explain() Explanation

Explain returns the full anatomy of this decision.

Safe to call on any Decision, including one that allowed the request: the result then carries the verdict and the evaluation count with no rule.

Example

Every block is explainable. Explain returns the matched span, the transform chain that produced it, and the narrowest exception that would suppress this exact finding without weakening the rule anywhere else.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
)

func main() {
	waf, _ := gwaf.New()
	tx := waf.NewTransaction()
	defer tx.Close()

	tx.SetRequestLine("GET", "/search", "HTTP/1.1")
	tx.AddArgument("q", "<script>alert(1)</script>")
	d := tx.ProcessRequestHeaders()

	e := d.Explain()
	fmt.Println("rule:", e.RuleID())
	fmt.Println("matched:", string(e.MatchedBytes()))

	if x, ok := e.NarrowestException(); ok {
		fmt.Printf("exception: rule %d on %s:%s\n", x.RuleID, x.Target, x.Key)
	}
}
Output:
rule: 3010
matched: <script>alert(1)</script>
exception: rule 3010 on ARGS:q

func (Decision) Interpretation

func (d Decision) Interpretation() string

Interpretation names the alternative decoding under which the match was found, or "none" when it matched the value exactly as sent.

A non-"none" value is the most useful line in the audit record for that request: it says the payload was invisible in the bytes on the wire and only appeared once the value was read the way the origin would read it.

func (Decision) Key

func (d Decision) Key() string

Key returns the specific key within the matched collection, if any.

func (Decision) LogValue

func (d Decision) LogValue() slog.Value

LogValue implements slog.LogValuer, so a Decision logs as structured fields rather than as a formatted string.

slog.Warn("request blocked", "waf", d)

Every field an operator needs to triage is present, and the interpretation is included because a payload found only under an alternative decoding is the single most confusing thing to see in a log: the bytes on the wire look harmless, and without this field the firewall appears to have malfunctioned.

func (Decision) MatchedSpan

func (d Decision) MatchedSpan() (types.Span, bool)

MatchedSpan returns the byte range within the evaluated value that matched. The second result is false when the decision was not caused by a rule match.

func (Decision) Message

func (d Decision) Message() string

Message returns the responsible rule's human-readable description.

func (Decision) Reason

func (d Decision) Reason() Reason

Reason returns why the verdict was reached.

func (Decision) RuleID

func (d Decision) RuleID() types.RuleID

RuleID returns the rule responsible, or zero when no single rule was.

func (Decision) RulesEvaluated

func (d Decision) RulesEvaluated() int

RulesEvaluated returns how many operators actually ran.

func (Decision) Score

func (d Decision) Score() int

Score returns the accumulated anomaly score.

func (Decision) Severity

func (d Decision) Severity() types.Severity

Severity returns the responsible rule's severity.

func (Decision) Status

func (d Decision) Status() int

Status returns the HTTP status to respond with when blocked. It is zero when the decision did not specify one and the caller's default applies.

func (Decision) String

func (d Decision) String() string

String returns a compact, greppable summary.

Deliberately one line and stable in shape, because the first thing anyone does with a Decision is print it while working out why a request was blocked.

func (Decision) Target

func (d Decision) Target() types.Target

Target returns the collection the match came from.

func (Decision) Verdict

func (d Decision) Verdict() Verdict

Verdict returns the outcome.

type Diagnostic added in v0.5.0

type Diagnostic struct {
	// ID and Msg identify the rule as it appears in a Decision.
	ID  types.RuleID
	Msg string

	// Reason states what the rule cannot do, in one sentence.
	Reason string

	// Fix names the option or helper that closes it.
	Fix string
}

Diagnostic reports a rule that compiled cleanly and will still not detect what its presence in the ruleset suggests it does.

This is not a compile error: every rule here is well-formed, and an embedder may have declined the missing capability on purpose. It is the gap between what a ruleset looks like it covers and what it covers, which is otherwise visible only by reading the engine.

The two cases it reports today are both ones gwaf shipped and could not see: an off-origin rule with no origins to compare against, and an argument rule with no body-phase counterpart, which inspects the query string while the payload arrives in JSON.

type Explanation

type Explanation struct {
	// contains filtered or unexported fields
}

Explanation is the full anatomy of one decision, as data.

gwaf ships no UI and never will, but "no UI" is not a licence to withhold information (CLAUDE.md §2b). Everything a control plane would want to draw — what fired, which bytes matched, how the input was normalised to get there, and the narrowest exception that would have allowed it — has to be reachable programmatically, or the missing accessor is a tier-1 API gap.

Why this hangs off Decision rather than off WAF

docs/INTEGRATION.md used to describe `waf.Explain(txID)`, and that API cannot exist: looking a transaction up by ID means the WAF remembers transactions, which is cross-request state and the first of the five ownership tests. gwaf analyses one request in isolation and keeps nothing. So the explanation travels with the decision the caller already holds.

An Explanation borrows nothing from the transaction arena — MatchedBytes is copied — so it stays valid after the transaction is closed and can be handed to an audit sink or a queue.

func (Explanation) Confidence

func (e Explanation) Confidence() types.Confidence

Confidence is the rule's declared confidence tier.

func (Explanation) Interpretation

func (e Explanation) Interpretation() string

Interpretation names the decoding under which the match was found, or "none" when the value matched as sent. A finding visible only under an alternative reading is the most confusing kind to meet in a log.

func (Explanation) Key

func (e Explanation) Key() string

Key is the specific value's name — a header name, an argument name, a JSON field path — or empty for unkeyed collections.

func (Explanation) MatchedBytes

func (e Explanation) MatchedBytes() []byte

MatchedBytes is a copy of the bytes that matched.

Copied rather than borrowed: the transaction arena is recycled on Close, and an explanation that dangles into a reused buffer is worse than no explanation at all — it reports a different request's data with total confidence.

func (Explanation) MatchedSpan

func (e Explanation) MatchedSpan() (types.Span, bool)

MatchedSpan is the byte range within the transformed value that matched, and whether there was one.

func (Explanation) Message

func (e Explanation) Message() string

Message is the rule's human-readable description.

func (Explanation) NarrowestException

func (e Explanation) NarrowestException() (rules.Exception, bool)

NarrowestException returns the tightest exception that would have allowed this request, and whether one exists.

"Tightest" means every field the finding pins down is pinned down: the rule, the request path, the collection, and the specific key. Suppressing that exception silences this finding and nothing else — not the same rule on another route, not another argument on the same route.

This is the whole point of computing it rather than leaving it to an operator. Under time pressure the exception a human writes is `{RuleID: 7002}`, because it is the one they can be sure will work, and it disables the rule everywhere. Handing back the narrow form makes the correct fix the cheap one.

It is a suggestion, not an endorsement. CLAUDE.md §6 prefers deleting a rule over excepting it, and a rule needing exceptions on many routes is a rule that is wrong rather than a rule that needs tuning.

func (Explanation) Reason

func (e Explanation) Reason() Reason

Reason is why.

func (Explanation) RuleID

func (e Explanation) RuleID() types.RuleID

RuleID is the rule that produced the decision, or zero if none did.

func (Explanation) RulesEvaluated

func (e Explanation) RulesEvaluated() int

RulesEvaluated is how many rules actually ran.

func (Explanation) Score

func (e Explanation) Score() int

Score is the accumulated anomaly score at the point of decision.

func (Explanation) Severity

func (e Explanation) Severity() types.Severity

Severity is the rule's declared severity.

func (Explanation) String

func (e Explanation) String() string

String renders the explanation as an operator would want to read it.

func (Explanation) Tags

func (e Explanation) Tags() []string

Tags are the rule's classification tags.

func (Explanation) Target

func (e Explanation) Target() types.Target

Target is the collection the matched value came from.

func (Explanation) TransformChain

func (e Explanation) TransformChain() []string

TransformChain is the normalization applied before the operator ran, in order. This is the step operators most often need and most often cannot get: a payload that looks harmless on the wire matched because it was decoded, and without the chain the finding reads as a malfunction.

func (Explanation) Verdict

func (e Explanation) Verdict() Verdict

Verdict is what gwaf recommends.

type FailMode

type FailMode uint8

FailMode selects what happens when gwaf cannot complete its analysis — budget exhaustion, a limit breach, or an internal error.

There is no safe default here, which is why the embedder must own it: availability versus security is a deployment decision, not a library one.

const (
	// FailClosed rejects requests that could not be fully analysed. Correct
	// when a missed attack is worse than a dropped request.
	FailClosed FailMode = iota

	// FailOpen permits requests that could not be fully analysed. Correct when
	// availability dominates. It is loud: every occurrence emits a decision
	// with ReasonBudget or ReasonLimit.
	FailOpen
)

func (FailMode) String

func (f FailMode) String() string

String implements fmt.Stringer.

type Limits

type Limits struct {
	// MaxBodySize is the largest request body inspected, in bytes.
	MaxBodySize int

	// MaxArgs is the largest number of arguments inspected.
	MaxArgs int

	// MaxHeaders is the largest number of headers inspected.
	MaxHeaders int

	// MaxValueLen is the largest single value inspected, in bytes.
	//
	// Exceeding it is a decision, never a truncation: a value too large to
	// inspect is not a value shown to be clean. Set it large enough for the
	// traffic you actually serve — base64 file content in a JSON or protobuf
	// field, and long bearer tokens in query parameters, since browsers cannot
	// set headers on WebSocket or EventSource connections.
	MaxValueLen int

	// MaxArenaSize bounds per-transaction working memory, in bytes.
	MaxArenaSize int
}

Limits bound the input gwaf will analyse.

These are enforced before parsing, as a cheap pre-check. They are both a denial-of-service defence and a latency guarantee: they bound worst-case work per request independently of ruleset size.

Exceeding a limit is a decision, never a truncation. Half-inspecting an oversized body is indistinguishable from a bypass, so the request is rejected (or allowed, per FailMode) rather than partially analysed.

func DefaultLimits

func DefaultLimits() Limits

DefaultLimits are sized for typical API traffic while keeping the worst case well inside the memory SLO in CLAUDE.md §2.

type Match

type Match struct {
	RuleID     types.RuleID
	Msg        string
	Severity   types.Severity
	Confidence types.Confidence
	Target     types.Target
	Key        string
	Span       types.Span

	// Interpretation names the alternative decoding that revealed the payload,
	// or "none" when it matched the bytes as sent.
	Interpretation string

	// Score is this match's contribution to the anomaly total.
	Score int
}

Match is one rule that fired during a transaction.

Decisions report the rule responsible for the outcome; this reports every rule that matched, including scoring rules that did not block on their own. Calibration needs the full set — a rule's false-positive rate is how often it matches benign traffic, whether or not that match decided anything — and so does any control plane explaining a decision to an operator.

type Mode

type Mode uint8

Mode selects whether decisions are enforced.

const (
	// Blocking enforces decisions. This is the default: the core ruleset ships
	// only Certain and High confidence rules, which is what makes blocking by
	// default defensible. A WAF that silently protects nothing is worse than no
	// WAF, because the operator believes they are covered.
	Blocking Mode = iota

	// DetectionOnly evaluates rules and reports decisions without enforcing
	// them. It is the rollout path, not the destination.
	DetectionOnly
)

func (Mode) String

func (m Mode) String() string

String implements fmt.Stringer.

type Option

type Option func(*config)

Option configures a WAF. Options are applied in order; later ones win.

func OnDecision

func OnDecision(fn func(Decision)) Option

OnDecision registers a callback invoked for every terminal decision. It runs on the request path, so it must not block.

func WithBlockStatus

func WithBlockStatus(code int) Option

WithBlockStatus sets the HTTP status reported for blocked requests when a rule does not specify one.

func WithException

func WithException(x rules.Exception) Option

WithException suppresses one rule in one place.

Exceptions are conjunctive and the narrow form is the short one: every field set must match, every field left zero matches anything. Decision.Explain() computes the tightest exception that would have allowed a request it blocked, so the correct fix is the one an operator can copy rather than derive.

waf, err := gwaf.New(gwaf.WithException(rules.Exception{
    RuleID: 7002,
    Path:   "/api/v1/query",
    Key:    "filter[$gt]",
    Note:   "this endpoint publishes Mongo operators as its filter DSL",
}))

An exception with nothing set is refused rather than honoured: it would disable every rule everywhere, and a configuration that does that by accident should not be expressible.

CLAUDE.md §6 prefers deleting a rule over excepting it. A rule needing exceptions on many routes is a rule that is wrong, not one that needs tuning.

Example

An exception suppresses one finding on one route without weakening the rule anywhere else. Prefer this to deleting a rule: the narrow form is the short one, and Explain hands you exactly this struct.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
	"github.com/gsoultan/gwaf/rules"
	"github.com/gsoultan/gwaf/types"
)

func main() {
	waf, _ := gwaf.New(gwaf.WithException(rules.Exception{
		RuleID: 2010,
		Path:   "/admin/query-console",
		Target: types.TargetArgs,
		Key:    "sql",
	}))

	tx := waf.NewTransaction()
	defer tx.Close()
	tx.SetRequestLine("POST", "/admin/query-console", "HTTP/1.1")
	tx.AddArgument("sql", "SELECT * FROM users WHERE id = 1 OR 1=1")
	d := tx.ProcessRequestHeaders()

	fmt.Println("blocked on the excepted route:", d.Blocked())

	// The same payload anywhere else is still blocked.
	tx2 := waf.NewTransaction()
	defer tx2.Close()
	tx2.SetRequestLine("GET", "/search", "HTTP/1.1")
	tx2.AddArgument("sql", "SELECT * FROM users WHERE id = 1 OR 1=1")
	fmt.Println("blocked elsewhere:", tx2.ProcessRequestHeaders().Blocked())
}
Output:
blocked on the excepted route: false
blocked elsewhere: true

func WithExceptions added in v0.4.0

func WithExceptions(xs ...rules.Exception) Option

WithExceptions applies several exceptions at once.

It exists for ruleset/profiles, which returns a platform's whole set:

waf, err := gwaf.New(gwaf.WithExceptions(profiles.WordPress()...))

Each is validated exactly as WithException validates one, so a malformed entry in a profile fails at construction rather than silently suppressing everything.

Example

ExampleWithExceptions applies a platform profile.

Some rules are correct in general and wrong for one field of one application. A WordPress comment carrying "<?php echo $name; ?>" really is PHP; it is benign because of where it lands -- a field that is stored and displayed, never executed -- and that is knowledge the application has and gwaf does not. Scoping costs almost nothing: the same payload one field over, or one route over, still blocks.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
	"github.com/gsoultan/gwaf/ruleset/profiles"
)

func main() {
	waf, err := gwaf.New(gwaf.WithExceptions(profiles.WordPress()...))
	if err != nil {
		panic(err)
	}

	post := func(path, field, value string) string {
		tx := waf.NewTransaction()
		defer tx.Close()
		tx.SetRequestLine("POST", path, "HTTP/1.1")
		tx.SetRemoteAddr("192.0.2.1")
		tx.AddRequestHeader("Content-Type", "application/x-www-form-urlencoded")
		tx.AddArgument(field, value)
		if d := tx.ProcessRequestHeaders(); d.Blocked() {
			return "blocked"
		}
		if d := tx.ProcessRequestBody(); d.Blocked() {
			return "blocked"
		}
		return "allowed"
	}

	const php = "In PHP you write <?php echo $name; ?> to print a variable"
	fmt.Println("comment field:", post("/wp-comments-post.php", "comment", php))
	fmt.Println("author field: ", post("/wp-comments-post.php", "author", php))
}
Output:
comment field: allowed
author field:  blocked

func WithFailMode

func WithFailMode(f FailMode) Option

WithFailMode selects what happens when analysis cannot complete.

func WithFuelLimit

func WithFuelLimit(f types.Fuel) Option

WithFuelLimit sets the per-transaction work ceiling. A non-positive value disables metering, which is intended for offline tooling — calibration, corpus replay — and must not be used for serving traffic.

func WithLimits

func WithLimits(l Limits) Option

WithLimits sets input limits. Zero-valued fields fall back to the defaults, so a caller can override one limit without restating the rest.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the logger. The library never constructs a global logger and never writes to one it was not given.

func WithMinConfidence

func WithMinConfidence(c0 types.Confidence) Option

WithMinConfidence sets the least-trustworthy rule tier that will be evaluated. It replaces the global paranoia-level dial: "only run rules at least this trustworthy" is a statement that can be defined precisely, where "paranoia level 3" cannot. See docs/RULES.md §8.

func WithMode

func WithMode(m Mode) Option

WithMode selects blocking or detection-only.

Example

Detection-only is the rollout path: rules are evaluated and reported, and nothing is blocked. Run it against real traffic, read the decisions, then switch to blocking once the log is clean.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
)

func main() {
	waf, _ := gwaf.New(gwaf.WithMode(gwaf.DetectionOnly))
	tx := waf.NewTransaction()
	defer tx.Close()

	tx.SetRequestLine("GET", "/search", "HTTP/1.1")
	tx.AddArgument("q", "1' OR 1=1--")
	d := tx.ProcessRequestHeaders()

	fmt.Println("blocked:", d.Blocked())
	fmt.Println("would have matched rule:", d.RuleID())
}
Output:
blocked: false
would have matched rule: 2010

func WithOrigins added in v0.4.1

func WithOrigins(hosts ...string) Option

WithOrigins declares the hostnames this application answers on.

Rules that ask whether a destination points somewhere else need something trustworthy to compare against, and the request is not it: an attacker supplies the Host header as freely as the destination, so comparing the two concludes same-origin whenever they are set to match. That was a live bypass in v0.4.0.

waf, err := gwaf.New(gwaf.WithOrigins("shop.example.com", "auth.example.com"))

Subdomains of a declared origin are accepted, so "shop.example.com" covers "www.shop.example.com". Ports are ignored.

Without it, the off-origin redirect and SSRF rules cannot establish that any destination is foreign and report nothing. That is deliberate: silently trusting the Host header would give a guarantee the request can revoke.

Example

ExampleWithOrigins shows why an open redirect needs configuration to detect.

"Is this destination somewhere else?" cannot be answered from the request alone, because the request supplies both sides: an attacker who sets Host to match their own destination makes any comparison against it conclude same-origin. Declaring the hostnames the application answers on is what makes the question answerable, and it is the difference between catching an open redirect and blocking a legitimate OAuth callback.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
)

func main() {
	waf, err := gwaf.New(gwaf.WithOrigins("shop.example.com"))
	if err != nil {
		panic(err)
	}

	check := func(destination string) string {
		tx := waf.NewTransaction()
		defer tx.Close()
		tx.SetRequestLine("GET", "/login?redirect_to="+destination, "HTTP/1.1")
		tx.SetRemoteAddr("192.0.2.1")
		// Attacker-chosen, and deliberately not what the rule trusts.
		tx.AddRequestHeader("Host", "evil.tld")
		if d := tx.ProcessRequestHeaders(); d.Blocked() {
			return "blocked"
		}
		return "allowed"
	}

	fmt.Println("own site: ", check("https://shop.example.com/cart"))
	fmt.Println("elsewhere:", check("https://evil.tld/phish"))
}
Output:
own site:  allowed
elsewhere: blocked

func WithParanoiaLevel

func WithParanoiaLevel(pl int) Option

WithParanoiaLevel maps a CRS paranoia level (1-4) onto a minimum confidence, so existing CRS configuration and operator knowledge keep working.

func WithRuleset

func WithRuleset(set rules.Set) Option

WithRuleset adds rules. It may be called more than once; sets accumulate.

Example

Rules are Go values, so a typo in a target name is a build failure rather than a rule that silently never fires at three in the morning.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
	"github.com/gsoultan/gwaf/rules"
	"github.com/gsoultan/gwaf/rules/op"
	"github.com/gsoultan/gwaf/types"
)

func main() {
	waf, err := gwaf.New(gwaf.WithRuleset(rules.Set{{
		ID:         1_000_001,
		Phase:      types.PhaseRequestHeaders,
		Targets:    []types.Target{{Kind: types.TargetRequestPath}},
		Op:         op.HasPrefix("/internal/"),
		Actions:    []rules.Action{rules.Block},
		Severity:   types.SeverityCritical,
		Confidence: types.Certain,
		Msg:        "internal-only path reached from outside",
	}}))
	if err != nil {
		panic(err)
	}

	tx := waf.NewTransaction()
	defer tx.Close()
	tx.SetRequestLine("GET", "/internal/metrics", "HTTP/1.1")
	d := tx.ProcessRequestHeaders()

	fmt.Println("blocked by:", d.RuleID())
}
Output:
blocked by: 1000001

func WithSchema

func WithSchema(s *schema.Schema) Option

WithSchema supplies an API description.

The schema is both a validator and a compiler input. Requests outside it are rejected before any rule runs, and values inside it that validate as a constrained type — an integer, a UUID, a declared enum — skip rule evaluation entirely, because such a value provably cannot carry a payload.

The better an API is specified, the faster and the more precise gwaf gets. See docs/CONCEPT.md §6 and §9.

Example

Describing the API is the highest-value thing an embedder can do. A field declared an integer that validates as one cannot contain "UNION SELECT", so those rules are skipped soundly rather than heuristically — the schema makes gwaf both faster and stricter at once.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
	"github.com/gsoultan/gwaf/schema"
)

func main() {
	api, err := schema.New(schema.Operation{
		Method: "POST",
		Path:   "/api/v1/bets",
		Strict: true,
		Body: []schema.Field{
			{Name: "stake", Kind: schema.KindNumber, Required: true,
				Min: schema.Bound(0.01), Max: schema.Bound(10_000)},
			{Name: "currency", Kind: schema.KindEnum, Enum: []string{"USD", "EUR"}},
		},
	})
	if err != nil {
		panic(err)
	}

	waf, _ := gwaf.New(gwaf.WithSchema(api))
	tx := waf.NewTransaction()
	defer tx.Close()

	tx.SetRequestLine("POST", "/api/v1/bets", "HTTP/1.1")
	tx.AddRequestHeader("Content-Type", "application/json")
	tx.ProcessRequestHeaders()
	tx.SetRequestBody([]byte(`{"stake":-5000,"currency":"USD"}`))
	d := tx.ProcessRequestBody()

	// A negative stake is a perfectly good number and no signature describes it.
	fmt.Println("blocked:", d.Blocked())
	fmt.Println("reason:", d.Reason())
}
Output:
blocked: true
reason: schema_violation

func WithThreshold

func WithThreshold(n int) Option

WithThreshold sets the anomaly score at or above which a request is blocked.

func WithoutCoreRuleset

func WithoutCoreRuleset() Option

WithoutCoreRuleset omits the first-party ruleset, leaving only rules the embedder supplies.

Use it when you are replacing detection wholesale — a migration running gwaf alongside another engine, or a test exercising specific rules. A WAF with no rules blocks nothing, so this is an explicit opt-out rather than a consequence of forgetting to configure something.

type Reason

type Reason uint8

Reason explains why a Decision reached its verdict.

const (
	// ReasonNoMatch means nothing matched.
	ReasonNoMatch Reason = iota

	// ReasonRule means a rule demanded a terminal outcome.
	ReasonRule

	// ReasonThreshold means the accumulated anomaly score crossed the policy
	// threshold.
	ReasonThreshold

	// ReasonBudget means the fuel budget was exhausted and the configured fail
	// mode decided the outcome. The ruleset was only partially evaluated, so
	// this verdict carries less information than the others — it says what the
	// deployment chose to do about not knowing, not that the request was clean.
	ReasonBudget

	// ReasonLimit means a hard input limit was exceeded before rules ran.
	ReasonLimit

	// ReasonSchema means the request fell outside the declared API description.
	//
	// This is positive security rather than signature matching: the request was
	// rejected for not being something the API accepts, without any claim about
	// what it was trying to do.
	ReasonSchema

	// ReasonDesync means the request's framing was ambiguous: Content-Length
	// and Transfer-Encoding disagreed, or one of them was malformed in a way
	// two parsers resolve differently.
	//
	// This is request smuggling, and it is the one reason FailOpen does not
	// soften: an ambiguously framed request is potentially two requests, the
	// second of which no firewall has seen.
	ReasonDesync

	// ReasonUndecidable means the input had more plausible interpretations than
	// gwaf will enumerate, so no verdict about it would be meaningful.
	//
	// This is deliberately distinct from ReasonNoMatch. A value too ambiguous
	// to analyse has not been shown to be clean, and reporting it as clean is
	// exactly the assumption that CVE-2026-21876 exploited.
	ReasonUndecidable
)

func (Reason) String

func (r Reason) String() string

String implements fmt.Stringer.

type Transaction

type Transaction struct {
	// contains filtered or unexported fields
}

Transaction analyses one request.

A Transaction is owned by exactly one goroutine for its entire lifetime. It is not safe for concurrent use, unlike the WAF that produced it.

Phases run in order and each may terminate the transaction. Blocking at ProcessRequestHeaders means the body is never read from the client, never parsed, and never transformed — the cheapest rules run first by construction.

func (*Transaction) AddArgument

func (tx *Transaction) AddArgument(name, value string)

AddArgument records one request argument.

func (*Transaction) AddRequestHeader

func (tx *Transaction) AddRequestHeader(name, value string)

AddRequestHeader records one request header.

Headers beyond the configured limit are not silently dropped: the count is tracked and ProcessRequestHeaders reports a limit breach, because a request that was only partly inspected must not be reported as clean.

func (*Transaction) AddResolver

func (tx *Transaction) AddResolver(r rules.Resolver)

AddResolver registers a source of embedder-supplied values for this request.

A resolver is how a signal gwaf deliberately does not compute reaches a rule: an IP reputation score, a JA4 fingerprint, a bot score, a tenant identifier. gwaf consumes the value; it never fetches, computes, or remembers one.

tx.AddResolver(myReputation{score: score, asn: asn})

Per transaction rather than per WAF, because a resolver almost always closes over data specific to one request, and a WAF is shared by every goroutine.

The resolver is called only if a rule in the phase reads its name, and at most once per phase. Do the work inside Resolve rather than before registering: skipping the call entirely is the point, since a signal is usually out of gwaf's scope because it is expensive.

Example

A Resolver is how a signal gwaf deliberately does not compute — reputation, a bot score, a tenant — reaches a rule. gwaf consumes the score; it never maintains one, because that would be state across requests.

package main

import (
	"fmt"
	"iter"

	"github.com/gsoultan/gwaf"
	"github.com/gsoultan/gwaf/rules"
	"github.com/gsoultan/gwaf/rules/op"
	"github.com/gsoultan/gwaf/types"
)

func main() {
	waf, _ := gwaf.New(gwaf.WithRuleset(rules.Set{{
		ID:         1_000_002,
		Phase:      types.PhaseRequestHeaders,
		Targets:    []types.Target{{Kind: types.TargetResolved, Name: "reputation.score"}},
		Op:         op.Equals("hostile"),
		Actions:    []rules.Action{rules.Block},
		Severity:   types.SeverityCritical,
		Confidence: types.Certain,
		Msg:        "request from a client the embedder scored hostile",
	}}))

	tx := waf.NewTransaction()
	defer tx.Close()
	tx.AddResolver(reputationResolver{score: "hostile"})
	tx.SetRequestLine("GET", "/", "HTTP/1.1")

	fmt.Println("blocked:", tx.ProcessRequestHeaders().Blocked())
}

// reputationResolver is the embedder's own store, seen from gwaf's side. The
// work happens inside Resolve and only when a rule in the phase actually reads
// the collection, so registering one costs nothing on requests that never
// consult it.
type reputationResolver struct{ score string }

func (reputationResolver) Name() string { return "reputation" }

func (r reputationResolver) Resolve() iter.Seq2[string, []byte] {
	return func(yield func(string, []byte) bool) {
		yield("score", []byte(r.score))
	}
}
Output:
blocked: true

func (*Transaction) AddResponseHeader

func (tx *Transaction) AddResponseHeader(name, value string)

AddResponseHeader records one response header.

func (*Transaction) BodyParseError

func (tx *Transaction) BodyParseError() string

BodyParseError returns why a structured body could not be parsed, or empty.

A body that fell back to whole-document inspection is still analysed, but less precisely — schema validation cannot apply to fields that were never extracted. Surfacing it lets an operator notice a client sending malformed JSON rather than discovering it as a coverage gap later.

func (*Transaction) Close

func (tx *Transaction) Close()

Close returns the transaction to its pool. It is safe to call more than once.

func (*Transaction) Decision

func (tx *Transaction) Decision() Decision

Decision returns the decision reached so far. Before any phase has produced a terminal outcome it reports an allowing decision.

func (*Transaction) FuelSpent

func (tx *Transaction) FuelSpent() types.Fuel

FuelSpent returns the work consumed so far.

func (*Transaction) Matches

func (tx *Transaction) Matches() []Match

Matches returns every rule that fired in the phase most recently evaluated.

The slice is owned by the transaction and is invalidated by the next phase or by Close. Callers that retain matches must copy them.

func (*Transaction) ProcessRequestBody

func (tx *Transaction) ProcessRequestBody() Decision

ProcessRequestBody evaluates the request-body phase.

func (*Transaction) ProcessRequestHeaders

func (tx *Transaction) ProcessRequestHeaders() Decision

ProcessRequestHeaders evaluates the request-headers phase.

func (*Transaction) ProcessResponseBody

func (tx *Transaction) ProcessResponseBody() Decision

ProcessResponseBody evaluates the response-body phase over everything WriteResponseBody was given.

func (*Transaction) ProcessResponseHeaders

func (tx *Transaction) ProcessResponseHeaders() Decision

ProcessResponseHeaders evaluates the response-headers phase.

Call it after the upstream status and headers are known and before the body is written, so a leak detectable from headers alone stops the response before any of it reaches the client.

func (*Transaction) RulesEvaluated

func (tx *Transaction) RulesEvaluated() int

RulesEvaluated returns how many operators have run. On benign traffic this must be zero; it is the leading indicator that the prefilter is working.

Example

Ordinary traffic costs almost nothing: the prefilter decides what to evaluate before any rule runs, so the count is a small constant that does not grow with the ruleset. Ten rules and ten thousand rules evaluate the same number.

The two requests below show the shape of it. A path with no attack vocabulary reaches zero rules; adding a query string makes exactly one rule a candidate, whatever the query says. That constant is the honest number — "zero rules on benign traffic" is true of the no-query case and rounds the other one down.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
)

func main() {
	waf, _ := gwaf.New()

	for _, target := range []string{"/products", "/products?id=42"} {
		tx := waf.NewTransaction()
		// SetRequestLine parses the query string itself, so query parameters
		// need no AddArgument -- that is for values the caller has already
		// decoded, such as form fields.
		tx.SetRequestLine("GET", target, "HTTP/1.1")
		tx.ProcessRequestHeaders()
		fmt.Printf("%-18s rules evaluated: %d\n", target, tx.RulesEvaluated())
		tx.Close()
	}
}
Output:
/products          rules evaluated: 0
/products?id=42    rules evaluated: 1

func (*Transaction) Score

func (tx *Transaction) Score() int

Score returns the accumulated anomaly score.

func (*Transaction) SetRemoteAddr

func (tx *Transaction) SetRemoteAddr(addr string)

SetRemoteAddr records the client address.

func (*Transaction) SetRequestBody

func (tx *Transaction) SetRequestBody(b []byte)

SetRequestBody records the request body.

A body whose Content-Type gwaf can structure is parsed into fields, and each field is recorded separately. That is both faster and more accurate than inspecting the document whole:

  • Faster, because detectors run over leaf values rather than the entire document, and because a field the schema constrains can be skipped.
  • More accurate, because a JSON string is not its bytes. `{"c":"\u003cscript\u003e"}` contains no angle bracket on the wire and the origin's parser hands the application `<script>`.

A body gwaf cannot structure — or one that fails to parse — is recorded whole instead. That is slower and less precise but never less safe, and a parse failure is surfaced at the phase boundary rather than silently ignored.

func (*Transaction) SetRequestLine

func (tx *Transaction) SetRequestLine(method, target, proto string)

SetRequestLine records the method, target, and protocol.

func (*Transaction) SetResponseStatus

func (tx *Transaction) SetResponseStatus(status int)

SetResponseStatus records the upstream status code.

func (*Transaction) UndeclaredRoute added in v0.2.0

func (tx *Transaction) UndeclaredRoute() bool

UndeclaredRoute reports whether a schema is configured and this request matched none of its operations — a *shadow endpoint*, in API-security terms.

Why this is only half the feature, on purpose

"You cannot protect APIs you do not know about" is the most common gap in API security programmes, and the endpoints nobody documented are where it lives. Finding them needs two things: noticing that one request went somewhere undeclared, and remembering that across requests to produce a list.

gwaf does the first and refuses the second. Aggregating is memory, and memory is the embedder's by the first ownership test (CLAUDE.md §1) — a WAF that kept a running inventory would need eviction, cardinality limits, and persistence, which is a database growing inside a request filter. So this reports one bit about one request and the embedder counts:

if tx.UndeclaredRoute() {
    inventory.Observe(r.Method, r.URL.Path) // the embedder's map, not gwaf's
}

Relationship to Schema.Closed

They are the two ends of the same observation. A closed schema *rejects* an undeclared route; this *reports* one. Discovery is what you run first — in an open schema, to learn which endpoints exist — and closing the schema is what you do once the inventory is complete. Reporting works in both modes, so the signal does not disappear at the moment enforcement starts.

It returns false when no schema is configured, because without one every route is undeclared and the answer would be noise rather than a finding.

Example

Discovering shadow endpoints: gwaf reports that one request went somewhere the schema does not describe, and the embedder keeps the inventory. Aggregating is memory, and memory belongs to the embedder.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
	"github.com/gsoultan/gwaf/schema"
)

func main() {
	api, _ := schema.New(schema.Operation{Method: "GET", Path: "/api/v1/orders", NoBody: true})
	waf, _ := gwaf.New(gwaf.WithSchema(api))

	inventory := map[string]int{}
	for _, path := range []string{"/api/v1/orders", "/internal/debug/config"} {
		tx := waf.NewTransaction()
		tx.SetRequestLine("GET", path, "HTTP/1.1")
		if tx.UndeclaredRoute() {
			inventory[path]++
		}
		tx.Close()
	}

	fmt.Println("shadow endpoints found:", len(inventory))
	fmt.Println("which:", inventory)
}
Output:
shadow endpoints found: 1
which: map[/internal/debug/config:1]

func (*Transaction) WriteResponseBody

func (tx *Transaction) WriteResponseBody(chunk []byte) Decision

WriteResponseBody hands gwaf a chunk of the response body.

It may be called repeatedly, which is how an embedder that streams feeds gwaf without buffering the whole response itself. Chunks accumulate into the transaction arena up to MaxBodySize; past that the response is reported as exceeding the inspection limit rather than being partly inspected and called clean.

The returned Decision is terminal only when a limit was breached. Content analysis happens in ProcessResponseBody, once the body is complete.

type Verdict

type Verdict uint8

Verdict is what a transaction concluded.

const (
	// VerdictAllow permits the request.
	VerdictAllow Verdict = iota

	// VerdictBlock rejects the request.
	VerdictBlock
)

func (Verdict) String

func (v Verdict) String() string

String implements fmt.Stringer.

type WAF

type WAF struct {
	// contains filtered or unexported fields
}

WAF is a compiled, ready-to-use firewall.

A WAF is safe for concurrent use by any number of goroutines. A Transaction obtained from it is not: each is owned by exactly one goroutine for its lifetime. That distinction is the most common misuse of every WAF library, so it is stated on both types.

A WAF holds no global state. Any number of independent instances may coexist in one process with different rulesets, which is what makes multi-tenant embedding and parallel tests work.

func New

func New(opts ...Option) (*WAF, error)

New compiles a WAF from options.

With no options it returns a working, blocking WAF: safe defaults, sensible limits, and no configuration files to find. Adding rules is additive.

Example

A working, blocking firewall in one line and no configuration.

The core ruleset ships Certain and High confidence rules only, which is what makes blocking safe by default: a WAF that ships in detection-only mode protects nothing while telling the operator they are covered.

package main

import (
	"fmt"

	"github.com/gsoultan/gwaf"
)

func main() {
	waf, err := gwaf.New()
	if err != nil {
		panic(err)
	}

	tx := waf.NewTransaction()
	defer tx.Close()

	tx.SetRequestLine("GET", "/products?id=1", "HTTP/1.1")
	tx.AddArgument("id", "1' OR 1=1--")

	d := tx.ProcessRequestHeaders()
	fmt.Println("blocked:", d.Blocked())
	fmt.Println("rule:", d.RuleID())
}
Output:
blocked: true
rule: 2010

func (*WAF) Compile

func (w *WAF) Compile(set rules.Set) (*rules.Ruleset, error)

Compile builds a ruleset using this WAF's confidence policy, for later use with SwapRuleset.

func (*WAF) Diagnostics added in v0.5.0

func (w *WAF) Diagnostics() []Diagnostic

Diagnostics returns the rules that compiled but cannot detect what they appear to, in ruleset order. An empty result is the healthy answer.

New logs the first of these once. It is returned as data because a log line is not an API: a control plane building a coverage view has to be able to ask (CLAUDE.md 2b -- every datum a UI would need is reachable programmatically).

for _, d := range waf.Diagnostics() {
    log.Warn("coverage gap", "rule", d.ID, "reason", d.Reason, "fix", d.Fix)
}

The result is computed once at construction from the ruleset New compiled. It does not follow SwapRuleset, which takes an already-compiled ruleset.

func (*WAF) Limits added in v0.5.0

func (w *WAF) Limits() Limits

Limits reports the configured bounds.

Exported so an integration can bound what it reads. A middleware buffering a request body has to know the ceiling the WAF will apply or it allocates without one: io.ReadAll on a client-controlled body spent 572 MiB reaching a verdict the engine reaches at 1 MiB, which makes the firewall the denial of service it exists to prevent (CLAUDE.md §2, invariant 3).

func (*WAF) Mode

func (w *WAF) Mode() Mode

Mode returns the configured enforcement mode.

func (*WAF) NewTransaction

func (w *WAF) NewTransaction() *Transaction

NewTransaction begins analysing one request.

The returned Transaction is owned by the calling goroutine and must be closed with Close, which returns its buffers to the pool. Failing to close leaks nothing permanently but forfeits the pooling that keeps steady-state allocation at zero.

func (*WAF) Report

func (w *WAF) Report() rules.Report

Report returns the active ruleset's compile summary.

func (*WAF) Ruleset

func (w *WAF) Ruleset() *rules.Ruleset

Ruleset returns the active compiled ruleset.

func (*WAF) Schema

func (w *WAF) Schema() *schema.Schema

Schema returns the configured API description, or nil.

func (*WAF) SwapRuleset

func (w *WAF) SwapRuleset(rs *rules.Ruleset)

SwapRuleset atomically replaces the active plan.

Compilation and swap are separate on purpose: a ruleset is validated off the request path and the swap itself cannot fail, so a bad ruleset never goes live. In-flight transactions complete against the plan they started with.

Directories

Path Synopsis
Package audit turns a gwaf decision into a record something else can store.
Package audit turns a gwaf decision into a record something else can store.
Package calibrate measures what a rule's confidence claim is actually worth.
Package calibrate measures what a rule's confidence claim is actually worth.
cmd
gwaf command
Command gwaf is the build-time toolchain.
Command gwaf is the build-time toolchain.
detect
graphql
Package graphql detects abusive GraphQL documents by reading their shape.
Package graphql detects abusive GraphQL documents by reading their shape.
javaser
Package javaser detects Java attacks by reading invocation structure rather than by listing class names.
Package javaser detects Java attacks by reading invocation structure rather than by listing class names.
ldapi
Package ldapi detects LDAP injection by reading filter syntax rather than by matching payload strings.
Package ldapi detects LDAP injection by reading filter syntax rather than by matching payload strings.
nosqli
Package nosqli detects NoSQL injection by reading where a name sits in a document rather than by matching strings in a value.
Package nosqli detects NoSQL injection by reading where a name sits in a document rather than by matching strings in a value.
phpi
Package phpi detects PHP injection by reading language structure rather than by listing function names.
Package phpi detects PHP injection by reading language structure rather than by listing function names.
promptinjection
Package promptinjection detects attempts to override an LLM's instructions from inside its input.
Package promptinjection detects attempts to override an LLM's instructions from inside its input.
shelli
Package shelli detects command injection by reading shell structure rather than by matching command names.
Package shelli detects command injection by reading shell structure rather than by matching command names.
sqli
Package sqli detects SQL injection by parsing structure rather than matching strings.
Package sqli detects SQL injection by parsing structure rather than matching strings.
ssti
Package ssti detects server-side template injection by reading what sits inside a template expression, never by the presence of one.
Package ssti detects server-side template injection by reading what sits inside a template expression, never by the presence of one.
xss
Package xss detects cross-site scripting by reading markup structure rather than matching strings.
Package xss detects cross-site scripting by reading markup structure rather than matching strings.
internal
bitset
Package bitset provides a fixed-capacity bitset used to carry candidate rule sets from the prefilter to the evaluator.
Package bitset provides a fixed-capacity bitset used to carry candidate rule sets from the prefilter to the evaluator.
body
Package body extracts individual fields from a request body.
Package body extracts individual fields from a request body.
budget
Package budget meters the work a single transaction is allowed to perform.
Package budget meters the work a single transaction is allowed to perform.
engine
Package engine evaluates a compiled ruleset against transaction data.
Package engine evaluates a compiled ruleset against transaction data.
interpret
Package interpret enumerates the plausible readings of an ambiguous value.
Package interpret enumerates the plausible readings of an ambiguous value.
memz
Package memz owns all allocation on the request hot path.
Package memz owns all allocation on the request hot path.
prefilter
Package prefilter implements the multi-pattern automaton that decides which rules are worth evaluating for a given input.
Package prefilter implements the multi-pattern automaton that decides which rules are worth evaluating for a given input.
scan
Package scan covers a whole value with bounded work.
Package scan covers a whole value with bounded work.
middleware module
Package rules defines how detection rules are authored, validated, and compiled into an executable plan.
Package rules defines how detection rules are authored, validated, and compiled into an executable plan.
op
Package op provides the built-in operators.
Package op provides the built-in operators.
op/rx
Package rx provides a regular-expression operator.
Package rx provides a regular-expression operator.
transform
Package transform provides the built-in value normalizations.
Package transform provides the built-in value normalizations.
ruleset
core
Package core provides the first-party ruleset loaded by gwaf.New.
Package core provides the first-party ruleset loaded by gwaf.New.
profiles
Package profiles holds per-platform exception sets.
Package profiles holds per-platform exception sets.
Package schema turns a description of an API into both a validator and a compiler input.
Package schema turns a description of an API into both a validator and a compiler input.
seclang module
Package telemetry counts what a WAF operator needs to see.
Package telemetry counts what a WAF operator needs to see.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL