rules

package
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: 7 Imported by: 0

Documentation

Overview

Package rules defines how detection rules are authored, validated, and compiled into an executable plan.

The canonical authoring form is a plain struct literal rather than a fluent builder. Structs serialize, diff in code review, and can be generated; builders do none of those well and add a state machine to get wrong. It is also what keeps the Go and declarative frontends isomorphic rather than merely similar — both produce the same Rule values and therefore the same compiled plan. See docs/RULES.md §2 and §3.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidRule reports a rule that cannot be compiled.
	ErrInvalidRule = errors.New("invalid rule")

	// ErrDuplicateID reports two rules sharing an ID. IDs appear in audit logs,
	// exceptions, and tuning guides, so a duplicate would make those ambiguous.
	ErrDuplicateID = errors.New("duplicate rule id")

	// ErrReservedID reports a rule placed in a range reserved for the core
	// ruleset, first-party bundles, or CRS.
	ErrReservedID = errors.New("rule id in reserved range")
)

Sentinel errors returned by Compile. Callers can match them with errors.Is even when several rules failed and the errors were joined.

View Source
var ErrExceptionTooBroad = errors.New("rules: exception matches everything; set at least RuleID")

ErrExceptionTooBroad reports an exception with no field set, which would suppress every rule everywhere. Refusing it is the point: an exception is a scalpel, and one with nothing set is a switch labelled "off".

Functions

This section is empty.

Types

type Action

type Action interface {
	// Name returns a stable identifier for reports and declarative formats.
	Name() string

	// Run returns what the engine should do about the match.
	Run(ctx *EvalContext, m Match) Outcome
}

Action runs when a rule matches.

Action is one of the five public extension points (docs/RULES.md §4) and is frozen under semver at v1.0. Implementations must be concurrent-safe and must not block: one instance is shared across transactions and Run executes on the request path.

var (
	// Block terminates the transaction with the policy's default status.
	Block Action = fixedAction{/* contains filtered or unexported fields */}

	// Log records the match without affecting the outcome.
	Log Action = fixedAction{/* contains filtered or unexported fields */}

	// Allow terminates evaluation and permits the request.
	Allow Action = fixedAction{/* contains filtered or unexported fields */}

	// Score contributes the rule's severity-derived score to the anomaly total.
	Score Action = fixedAction{/* contains filtered or unexported fields */}
)

Built-in actions. These are values rather than constructors where they carry no configuration, so a rule literal reads as data.

func BlockWithStatus

func BlockWithStatus(status int) Action

BlockWithStatus returns an Action that blocks with a specific HTTP status.

func ScoreBy

func ScoreBy(n int) Action

ScoreBy returns an Action contributing a fixed anomaly score.

type ActionKind

type ActionKind uint8

ActionKind enumerates the built-in outcomes of a rule match.

const (
	// ActionScore contributes to the transaction's anomaly score. The policy
	// threshold decides whether the accumulated score blocks.
	ActionScore ActionKind = iota

	// ActionBlock terminates the transaction immediately.
	ActionBlock

	// ActionAllow terminates rule evaluation and permits the request. It exists
	// for explicit allowlisting and is deliberately hard to reach: it must be
	// scoped, since an over-broad allow silently disables protection.
	ActionAllow

	// ActionLog records the match without influencing the outcome.
	ActionLog
)

Action kinds.

func (ActionKind) String

func (k ActionKind) String() string

String implements fmt.Stringer.

type Args added in v0.4.0

type Args struct {
	Names  [][]byte
	Values [][]byte
}

Args is a read-only view of a request's arguments.

It is a slice of pairs rather than a map because building a map per request would allocate, and the argument count is small enough that a scan is cheaper. The engine owns the backing memory; do not retain it.

type ChainGroup

type ChainGroup struct {
	// Transforms is the chain every rule in this group applies.
	Transforms []Transform

	// Rules are the group's rules, in evaluation order. Prefilter candidate
	// indices address this slice.
	Rules []*CompiledRule

	// Automaton maps literal occurrences in the transformed value to indices
	// into Rules. It is nil when every rule in the group is unconditional.
	Automaton *prefilter.Automaton

	// Unconditional lists indices into Rules that must run regardless.
	Unconditional []int
	// contains filtered or unexported fields
}

ChainGroup holds the rules that share one transform chain, together with the prefilter built from their literals.

Grouping by chain is what makes prefiltering correct as well as fast. An operator states its required literals in terms of the value it will actually see — that is, after transformation. A rule matching "unionselect" under a whitespace-stripping chain would never fire if the prefilter scanned the raw bytes, because the raw request says "UNION SELECT". Scanning the transformed value with an automaton built from the same chain's literals keeps the two in agreement.

It is also the common-subexpression elimination described in docs/CONCEPT.md §1.3: every rule sharing a chain pays for that chain once per value, not once per rule.

func (*ChainGroup) Reads

func (g *ChainGroup) Reads(k types.TargetKind) bool

Reads reports whether any rule in this group inspects the given target kind.

type CompiledRule

type CompiledRule struct {
	Rule *Rule
	// contains filtered or unexported fields
}

CompiledRule is the immutable, evaluation-ready form of a Rule.

It is exported so that explain output and compile reports can reference it, but it is constructed only by Compile.

func (*CompiledRule) Actions

func (c *CompiledRule) Actions() []Action

Actions returns the rule's actions, resolving the empty case to the default. The engine uses this rather than Rule.Actions so the default is applied in exactly one place.

func (*CompiledRule) Unconditional

func (c *CompiledRule) Unconditional() bool

Unconditional reports whether this rule runs regardless of input.

type EvalContext

type EvalContext struct {
	// Target is the collection the value came from.
	Target types.Target

	// Key is the specific key within a keyed collection — a header name, an
	// argument name — or empty for unkeyed targets.
	Key string

	// Method, RequestURI and Host describe the request the value arrived in.
	// They are the same for every value in a transaction and are populated once
	// per phase, so reading them costs nothing.
	//
	// They exist because some questions are not answerable from a value alone.
	// "Is this URL a destination the attacker chose?" needs the request's own
	// origin to compare against — without it, a rule cannot tell
	// "redirect_to=https://app.example.com/cb" arriving at app.example.com from
	// the same bytes arriving anywhere else, and has to choose between missing
	// open redirects and blocking OAuth. Route also carries real evidence:
	// "file=functions.php" is the WordPress theme editor working on
	// /wp-admin/theme-editor.php and local file inclusion on /download.
	//
	// These are bytes, not strings, because they are read on the request path
	// and materialising three strings per transaction would cost the
	// zero-allocation benign case. They point into the transaction's arena and
	// follow the same rule as everything else here: read them, do not retain
	// them.
	//
	// Any of them may be empty — a request need not carry a Host header, and a
	// value can be evaluated in a phase before the request line was set. An
	// operator that requires one must handle its absence rather than assume it.
	Method     []byte
	RequestURI []byte
	Host       []byte

	// Origins are the hostnames the embedder declared as its own, via
	// gwaf.WithOrigins. Empty when none were declared.
	//
	// This exists because Host does not. A rule that asks "is this destination
	// somewhere else?" cannot answer it from the request, because the request
	// supplies both sides: set Host to match the destination and any comparison
	// against it concludes same-origin. That was a real bypass in v0.4.0's
	// off-origin rule, and the lesson generalises -- a verdict that depends on
	// attacker-supplied data is not a verdict.
	//
	// Who the application is, is configuration. It is the embedder's to state
	// and gwaf's to trust.
	Origins []string

	// Siblings are the other arguments of the same request, when the engine has
	// them. It is nil outside the argument collections.
	//
	// It exists for the payload that is not in any single value. An application
	// that joins "path" and "target" reads "/etc/" and "passwd" and opens
	// /etc/passwd; a rule looking at one argument at a time sees a directory and
	// a word, and neither is an attack. Splitting a payload across parameters is
	// a documented technique and per-argument inspection is structurally blind
	// to it.
	//
	// Reading it is deliberately awkward, and that is the point: an operator
	// that walks every sibling on every value turns per-request work quadratic
	// in the argument count. Use SiblingValue to ask for one name.
	Siblings Args
}

EvalContext carries the context an operator may need beyond the value itself.

What may be trusted

The fields come from two places that look identical in Go and are not:

  • Target, Key and Origins are trustworthy. The first two are how gwaf parsed the request rather than what the request said; Origins is embedder configuration, set with gwaf.WithOrigins.
  • Method, RequestURI, Host and Siblings are attacker-controlled. All four are bytes the client chose.

Attacker-controlled context is evidence, never ground truth. It may raise suspicion; it may not be the thing that clears a value. The direction is what matters: Siblings used to convict -- "path=/etc/ together with target=passwd" -- is sound, because supplying it gains an attacker nothing. Host used to acquit -- "this destination matches Host, so it is same-origin" -- is a bypass, because the attacker supplies both sides and sets them equal.

That was a real bypass in v0.4.0's OffOriginURLRule, fixed in v0.4.1 by comparing against Origins. docs/RULES.md §4 has the table and the reasoning.

It is passed by pointer and is owned by the engine; operators must not retain it or any slice reachable from it beyond the Eval call, because the backing arena is recycled when the transaction ends.

func (*EvalContext) SiblingValue added in v0.4.0

func (c *EvalContext) SiblingValue(name string) ([]byte, bool)

SiblingValue returns the value of another argument of the same request.

The comparison is case-sensitive, because argument names are.

type Exception

type Exception struct {
	// RuleID is the rule to suppress. Zero matches every rule, which is almost
	// never what anyone wants; set it.
	RuleID types.RuleID

	// Path suppresses only for requests whose path matches. A trailing "*"
	// makes it a prefix, so "/admin/*" covers a subtree.
	Path string

	// Target restricts the suppression to one collection — arguments, headers,
	// the body. Zero matches any.
	Target types.TargetKind

	// Key restricts it to one named value: a header name, an argument name, a
	// JSON field path. Empty matches any.
	Key string

	// Note records why. It is carried through to audit output, because an
	// exception with no rationale is indistinguishable from a mistake six
	// months later.
	Note string
}

Exception suppresses one rule in one place.

Every WAF eventually needs one, because every ruleset eventually meets an application that legitimately sends something the ruleset calls an attack: a CMS whose users author Jinja templates, a paste bin that returns private keys, an API that publishes MongoDB operators as its filter DSL. The question is never whether exceptions exist — it is how *narrow* they can be made.

gwaf answers that by making the narrow form the easy one. An Exception is a conjunction: every field that is set must match, and every field left zero matches anything. So the tightest possible suppression is also the most specific literal:

rules.Exception{RuleID: 7002, Path: "/api/v1/query", Key: "filter[$gt]"}

and the widest is the one that looks widest:

rules.Exception{RuleID: 7002}

This ordering is deliberate. A tuning API where the blunt instrument is shorter to type is a tuning API that produces blunt instruments, and the working agreement in CLAUDE.md §6 — "prefer deleting a rule over adding an exception to it" — only means anything if the exception someone writes is the smallest one that works.

Decision.Explain().NarrowestException() computes exactly that for a finding that already happened, so an operator does not have to derive it by hand.

func (Exception) Matches

func (e Exception) Matches(rule, derivedFrom types.RuleID, path string, target types.Target, key string) bool

Matches reports whether this exception suppresses a finding.

Conjunctive: a field left zero matches anything, and every field that is set must match. An Exception with nothing set matches everything, which is why Validate rejects it.

derivedFrom is the ID the matched rule was generated from, if any. An exception against an authored rule covers its generated counterparts, because they are the same detection at another phase -- see Rule.DerivedFrom.

func (Exception) Validate

func (e Exception) Validate() error

Validate reports whether the exception is specific enough to be meaningful.

type Exceptions

type Exceptions []Exception

Exceptions is a set of exceptions, checked as a whole.

func (Exceptions) Suppresses

func (xs Exceptions) Suppresses(rule, derivedFrom types.RuleID, path string, target types.Target, key string) (Exception, bool)

Suppresses reports whether any exception covers this finding.

type Match

type Match struct {
	// Span locates the match within the value passed to Eval, not within the
	// original request buffer. The engine translates it for reporting.
	Span types.Span
}

Match describes where inside an evaluated value an operator matched.

The span makes every decision explainable: a block carries the exact bytes that caused it, which is what turns false-positive triage from archaeology into a diff. An operator that cannot report a span should report the whole value rather than a zero span.

func WholeValue

func WholeValue(value []byte) Match

WholeValue returns a Match covering all of value.

type Operator

type Operator interface {
	// Name returns a stable identifier, used in compile reports, explain
	// output, and to reference the operator from declarative rule formats.
	Name() string

	// Eval reports whether value matches.
	Eval(ctx *EvalContext, value []byte) (Match, bool)

	// Literals returns the byte sequences that must be present for this
	// operator to have any chance of matching, and whether that requirement is
	// exact.
	//
	// When the bool is true the engine may skip evaluation entirely if none of
	// the literals appear in the input, which is what keeps benign traffic off
	// the evaluation path. When it is false the rule is unconditional and runs
	// on every request; the compiler reports those and `gwaf lint` budgets
	// them, so the cost is visible at build time rather than in a latency
	// graph. See docs/RULES.md §5.
	//
	// Returning true with literals that are not genuinely required is the one
	// way to make the engine silently miss matches. It is an assertion, and it
	// is the caller's to justify.
	Literals() ([]string, bool)

	// Cost returns the fuel charged per evaluation, excluding any per-byte
	// component the engine adds. It must not depend on the input.
	Cost() types.Fuel
}

Operator decides whether a transformed value matches.

Operator is one of the five public extension points (docs/RULES.md §4). It is frozen under semver at v1.0 because third parties implement it, so changes to this signature are a major design decision rather than a refactor.

Implementations must be safe for concurrent use: one Operator instance is shared by every transaction evaluating the rule that holds it.

type Options

type Options struct {
	// UserRulesOnly rejects rules outside the embedder-owned ID range. It is
	// set when compiling rules supplied by an application so that a typo cannot
	// shadow a core or CRS rule ID and silently change what a tuning guide
	// refers to.
	UserRulesOnly bool
}

Options controls compilation.

type Outcome

type Outcome struct {
	Kind ActionKind

	// Score is the anomaly contribution for ActionScore. Zero means the rule's
	// severity decides.
	Score int

	// Status is the HTTP status for ActionBlock. Zero means the policy default.
	Status int
}

Outcome is what an Action asks the engine to do. It is data rather than a callback so that the engine, not the action, owns control flow — an action cannot skip phases, mutate the transaction, or block on I/O.

func (Outcome) Terminal

func (o Outcome) Terminal() bool

Terminal reports whether this outcome ends rule evaluation.

type Report

type Report struct {
	// Rules is the total number compiled.
	Rules int

	// Prefiltered is the number that will only be evaluated when their literals
	// appear in the input.
	Prefiltered int

	// Unconditional lists rules that run on every request in their phase.
	Unconditional []UnconditionalRule

	// Literals is the number of distinct literals in the prefilter.
	Literals int

	// AutomatonStates is the total prefilter state count across groups, a proxy
	// for prefilter memory.
	AutomatonStates int

	// ChainGroups is the number of distinct transform chains. Each one is
	// applied once per value, so this is the per-value normalization cost —
	// the figure to watch when adding rules with novel transform chains.
	ChainGroups int
}

Report summarizes what Compile produced. It is the data behind `gwaf lint` and is what makes the cost of unconditional rules visible at build time rather than in a production latency graph. See docs/RULES.md §5.

type Resolver

type Resolver interface {
	// Name identifies the collection. A rule reads it with
	// types.Target{Kind: types.TargetResolved, Name: "bot_score"}.
	//
	// It must be stable: it appears in compile reports, in audit records, and in
	// any exception written against a rule that reads it.
	Name() string

	// Resolve yields the values, keyed within the collection.
	//
	// Several values may share a collection the way headers do — a "reputation"
	// resolver might yield "score", "asn", and "categories" — and a rule can
	// select one by setting Target.Name to the resolver and matching on the key,
	// or read them all.
	//
	// The values are copied into the transaction as they are yielded, so an
	// implementation may reuse its buffers between them. Stopping early is
	// honoured: yield returning false means the engine has what it needs.
	Resolve() iter.Seq2[string, []byte]
}

Resolver supplies the values of a TargetResolved collection.

It is how a signal gwaf deliberately does not compute reaches a rule that wants to match on it: an IP reputation score, a JA4 fingerprint, a bot score, a tenant identifier, an authentication outcome.

Why this exists at all

CLAUDE.md §1 draws the scope line at "one request, no memory": anything needing state across requests, identity, privilege, or a network call belongs to the embedder. That line only works if the *results* of the embedder's work have a way in. Without a Resolver, an embedder computing a bot score has nowhere to put it, and the documented boundary has no implementation — rules can only ever see bytes gwaf read off the wire.

gwaf consumes a reputation score. It never maintains one, never fetches one, and never caches one. The Resolver is called; it does not call back.

Registered per transaction, not per WAF

A resolver almost always closes over data specific to one request — the score *this* client got, the tenant *this* token belongs to. A WAF is shared by every goroutine, so a per-WAF resolver would either need locking or be wrong. `Transaction.AddResolver` takes it for the request it belongs to.

Called only when a rule needs it

The engine asks the compiled plan whether any rule in the phase reads this resolver's name, and skips the call entirely when none does. That matters because the whole reason a signal is out of scope is usually that it is expensive: a reputation lookup, a fingerprint computation, a database read. Paying for it on every request when three prefiltered rules use it would undo the point.

A Resolver is therefore permitted to be slow, and is expected to be lazy — do the work inside Resolve, not before registering.

type Rule

type Rule struct {
	// DerivedFrom names the rule this one was generated from, or zero when it
	// was authored directly.
	//
	// Generated counterparts -- the request-body mirror of a header-phase rule,
	// the response-body mirror of a header one -- are the same detection at a
	// different phase, and they carry different IDs so audit logs stay
	// unambiguous. An exception written against the original therefore has to
	// cover the derivative, or an operator excepts "SQL injection on this
	// field" and is blocked one phase later by "SQL injection (body)" with an
	// ID they have never seen. That is the most confusing possible outcome of
	// adding an exception, so the relationship is recorded rather than inferred.
	DerivedFrom types.RuleID

	// ID is the stable public identifier. User rules must be in the range
	// types.UserMin..types.UserMax; the compiler rejects collisions and rules
	// placed in reserved ranges.
	ID types.RuleID

	// Phase selects when the rule runs. Zero is invalid.
	Phase types.Phase

	// Targets selects the values to inspect. At least one is required: a rule
	// with no targets inspects nothing and would silently never match.
	Targets []types.Target

	// Transforms normalize each value before Op sees it, applied in order.
	Transforms []Transform

	// Op decides whether a transformed value matches. Required.
	Op Operator

	// Actions run on a match, in order. Empty means Score, which is the safe
	// default: a rule that matches and does nothing is almost always an
	// authoring mistake, and scoring makes it visible without blocking.
	Actions []Action

	// Severity describes the impact of what this rule detects.
	Severity types.Severity

	// Confidence states how likely a match is a true positive.
	//
	// This is not an opinion: `gwaf calibrate` measures each rule's actual
	// false-positive rate against the benign corpus and fails the build when
	// the measurement exceeds the declared tier's ceiling. See
	// docs/CONCEPT.md §8.
	Confidence types.Confidence

	// Msg is a human-readable description shown in decisions and audit output.
	Msg string

	// Tags group rules for policy selection and exceptions.
	Tags []string
}

Rule is one detection rule.

A Rule is immutable once compiled and is shared across every transaction that evaluates it, so it and everything reachable from it must be concurrent-safe.

func (*Rule) HasTag

func (r *Rule) HasTag(tag string) bool

HasTag reports whether r carries tag.

type RuleError

type RuleError struct {
	ID    types.RuleID
	Field string
	Err   error
	// Hint, when set, states the fix rather than only the problem.
	Hint string
}

RuleError identifies which rule failed validation and why.

Compile reports every problem it finds rather than stopping at the first, so a ruleset with several mistakes is fixed in one pass instead of one error at a time.

func (*RuleError) Error

func (e *RuleError) Error() string

Error implements error.

func (*RuleError) Unwrap

func (e *RuleError) Unwrap() error

Unwrap allows errors.Is to reach the underlying sentinel.

type Ruleset

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

Ruleset is a compiled, immutable, concurrency-safe plan.

Compilation is total: either every rule compiled or Compile returned an error. There is no partially loaded ruleset and no silently skipped rule, which is what makes a swap at runtime safe.

func Compile

func Compile(set Set, opts Options) (*Ruleset, error)

Compile validates rules and builds an executable plan.

Every problem found is reported, not just the first, so a ruleset with several mistakes can be fixed in one pass.

func (*Ruleset) All

func (rs *Ruleset) All() []*CompiledRule

All returns every compiled rule, in evaluation order.

It exists for tooling that has to reason about the whole ruleset — the calibration harness, the linter, a control plane listing what is loaded — rather than about one request.

func (*Ruleset) ByID

func (rs *Ruleset) ByID(id types.RuleID) (*CompiledRule, bool)

ByID returns the compiled rule with the given ID.

func (*Ruleset) Groups

func (rs *Ruleset) Groups(p types.Phase) []*ChainGroup

Groups returns the transform-chain groups for a phase. The engine walks these rather than the flat rule list: each group's chain is applied once per value, then its automaton selects the candidates to evaluate.

func (*Ruleset) Len

func (rs *Ruleset) Len() int

Len returns the number of compiled rules.

func (*Ruleset) MaxChainLen

func (rs *Ruleset) MaxChainLen(p types.Phase) int

MaxChainLen returns the longest transform chain in a phase, so the evaluator can size its staging buffers once.

func (*Ruleset) MaxGroupRules

func (rs *Ruleset) MaxGroupRules(p types.Phase) int

MaxGroupRules returns the largest group size in a phase, which bounds the candidate bitset the evaluator needs.

func (*Ruleset) MirrorsOnly

func (rs *Ruleset) MirrorsOnly(p types.Phase) bool

MirrorsOnly reports whether a phase contains only generated counterparts, so the caller may restrict it to values that arrived in this phase.

func (*Ruleset) NeedsResolver

func (rs *Ruleset) NeedsResolver(p types.Phase, name string) bool

NeedsResolver reports whether any rule in the phase reads the named resolved collection.

An empty Name on a rule's target means "any resolver", so a rule written that way makes every registered resolver needed.

func (*Ruleset) PhaseRules

func (rs *Ruleset) PhaseRules(p types.Phase) []*CompiledRule

PhaseRules returns the rules compiled for a phase, in evaluation order.

func (*Ruleset) Reads added in v0.2.1

func (rs *Ruleset) Reads(k types.TargetKind) bool

Report returns the compile summary. Reads reports whether any rule in the ruleset, in any phase, inspects the given target kind.

The transaction uses this to skip *building* a value nothing will look at. REQUEST_LINE is the case that motivated it: only SecLang rules read it, the core ruleset has none, and reconstructing "METHOD URI PROTOCOL" for every request charged every embedder for a target only a CRS import uses. It cost enough to push the benign POST p50 past its 15µs budget, which is the kind of thing the compiler is supposed to notice at build time rather than pay for at request time (docs/CONCEPT.md).

func (*Ruleset) Report

func (rs *Ruleset) Report() Report

type Set

type Set []Rule

Set is an ordered collection of rules.

Order in the source does not affect evaluation order — the compiler sorts by (phase, ID) so that a decision is reproducible regardless of how rules were assembled. See docs/RULES.md §6.

func Concat

func Concat(sets ...Set) Set

Concat returns the concatenation of sets. It is a convenience for assembling a ruleset from a core set plus application rules.

type Transform

type Transform interface {
	// Name returns a stable identifier used in compile reports, explain output,
	// and declarative rule formats.
	Name() string

	// Apply writes the normalized form of src into dst and returns the result.
	//
	// dst is a scratch buffer owned by the caller with capacity for at least
	// MaxOutputLen(len(src)) bytes; implementations should append to dst[:0] and
	// return the result rather than allocating.
	//
	// The bool reports whether anything actually changed. Returning false lets
	// the engine skip the copy and keep using src, which is the common case for
	// already-normalized traffic and a large part of why benign requests
	// allocate nothing.
	Apply(dst, src []byte) ([]byte, bool)

	// MaxOutputLen returns an upper bound on the output length for an input of
	// the given length. The engine uses it to size scratch space up front, so
	// an implementation that exceeds its own bound will have its output
	// truncated by the arena limit — which the engine treats as a failed
	// transform rather than silently inspecting partial data.
	MaxOutputLen(srcLen int) int
}

Transform normalizes a value before an operator sees it.

Transforms exist because attackers encode payloads and origins decode them. A WAF that inspects only the raw bytes is trivially bypassed, and one that decodes differently from the origin is bypassed in a subtler way — that mismatch is the CVE-2026-21876 failure class.

Transform is one of the five public extension points (docs/RULES.md §4) and is frozen under semver at v1.0.

Implementations must be:

  • Pure. The same input always produces the same output. Transform results are memoised per transaction, so an impure transform produces a decision that depends on evaluation order.
  • Concurrent-safe. One instance is shared across all transactions.
  • Non-expanding, or explicitly bounded. Output longer than input is allowed but must be bounded by a constant factor, since output length feeds the arena and therefore the memory budget.

type UnconditionalRule

type UnconditionalRule struct {
	ID       types.RuleID
	Phase    types.Phase
	Operator string
	Reason   string
}

UnconditionalRule identifies a rule that cannot be prefiltered, and why.

Directories

Path Synopsis
op
Package op provides the built-in operators.
Package op provides the built-in operators.
rx
Package rx provides a regular-expression operator.
Package rx provides a regular-expression operator.
Package transform provides the built-in value normalizations.
Package transform provides the built-in value normalizations.

Jump to

Keyboard shortcuts

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