Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m13s

Every *.rules file in SWWAF_RULES_DIR is read at start and on each
change, and each request is checked against the rules after the rate
limits: log notes a match, block refuses with 403, ban refuses and bans
the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its next
request or clear sign of attack. path, query and uri are matched as the
request line sent them. bans.json gains each ban's cause, and ban notes
count earlier bans by cause. The image ships 00-default.rules.

Judgement call: a header sent twice is matched with its values joined
by ", ".
Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack.
Not in this unit: offences for rule matches, with the error burst.

Model: opus-5-5
This commit is contained in:
2026-10-06 14:33:26 +00:00
parent 6ec52e5b87
commit 7f472c40e2
27 changed files with 2164 additions and 263 deletions
+415
View File
@@ -0,0 +1,415 @@
// Package rules reads the rule files: the plain text files in
// SWWAF_RULES_DIR, one rule to a line, that each request is checked
// against, as the "Rule files" section of SPEC.md describes. They are read
// at start, and again whenever one is edited, added or removed.
package rules
import (
"context"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync/atomic"
"github.com/fsnotify/fsnotify"
)
// The actions a rule takes when it matches.
const (
// ActionLog notes the match in the request log, and does nothing else.
ActionLog = "log"
// ActionBlock refuses the request with 403.
ActionBlock = "block"
// ActionBan refuses the request and bans the client's netblock: the
// request is a clear sign of attack.
ActionBan = "ban"
)
// extension ends the name of every rule file.
const extension = ".rules"
// headerTarget starts the target that is one request header,
// header:<Name>.
const headerTarget = "header:"
// escapeLength is the length of a percent escape, such as %2e.
const escapeLength = 3
var (
// ruleLine is a rule: four fields separated by spaces or tabs, of
// which the fourth, the regex, runs to the end of the line.
ruleLine = regexp.MustCompile(`^([^ \t]+)[ \t]+([^ \t]+)[ \t]+([^ \t]+)[ \t]+(.+)$`)
// idChars are the characters of a rule's id.
idChars = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
var (
errNotRule = errors.New(
"is not a rule: an id, a target, an action and a regex, " +
"separated by spaces or tabs")
errNotID = errors.New("is not an id of letters, digits, - and _")
errNotTarget = errors.New(
"is not path, query, uri, method, host, user_agent, referer or header:<Name>")
errNotAction = errors.New("is not log, block or ban")
errNotRegex = errors.New("does not compile")
errUsedTwice = errors.New("is already the id of the rule at")
)
// Rule is one rule of a rule file.
type Rule struct {
// ID names the rule in the request log, the metrics and ban notes.
ID string
// Target is what the regex is matched against, such as path or
// header:Accept.
Target string
// Action is ActionLog, ActionBlock or ActionBan.
Action string
regex *regexp.Regexp
}
// Params are what Load needs.
type Params struct {
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
Dir string
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
// and no rule loaded.
Enabled bool
// ProcessLog receives how many rules were read, and the error in a
// rule file edited while smallwebwaf runs.
ProcessLog *slog.Logger
}
// Files are the rule files of a running smallwebwaf, and the rules read
// from them. They are safe for concurrent use.
type Files struct {
params Params
// rules are the rules loaded, in the order of their files' names, and
// then of their lines.
rules atomic.Pointer[[]Rule]
}
// Load reads the rules of every *.rules file in Dir, in the order of the
// files' names, unless Enabled is false. A Dir that cannot be read is an
// error, and so is a line that is not a rule, a regex that does not
// compile and an id used twice, each named with its file and line.
func Load(params Params) (*Files, error) {
f := &Files{params: params}
f.rules.Store(&[]Rule{})
if !params.Enabled {
return f, nil
}
rules, err := read(params.Dir)
if err != nil {
return nil, err
}
f.rules.Store(&rules)
f.logRead(len(rules))
return f, nil
}
// Match checks r against the rules, in order, and returns those it
// matches, up to the first whose action refuses it, block or ban, which
// is then the last one returned.
func (f *Files) Match(r *http.Request) []Rule {
var matched []Rule
for _, rule := range *f.rules.Load() {
if !rule.matches(r) {
continue
}
matched = append(matched, rule)
if rule.Action != ActionLog {
break
}
}
return matched
}
// Len returns how many rules are loaded.
func (f *Files) Len() int {
return len(*f.rules.Load())
}
// Watch watches Dir until ctx is done, and reads the rule files again
// whenever one is edited, added or removed. If they then hold an error,
// the rules stay as they were, the error is logged with its file and
// line, and the files are read again at the next change. If Dir cannot be
// watched, that is logged, and the rules stay as they were loaded. While
// Enabled is false, Watch returns at once.
func (f *Files) Watch(ctx context.Context) {
if !f.params.Enabled {
return
}
watcher, err := fsnotify.NewWatcher()
if err == nil {
defer func() {
_ = watcher.Close()
}()
err = watcher.Add(f.params.Dir)
}
if err != nil {
f.params.ProcessLog.Error("cannot watch the rule files for edits",
"error", err.Error())
return
}
f.params.ProcessLog.Info("watching the rule files for edits",
"directory", f.params.Dir)
for {
select {
case <-ctx.Done():
return
case event := <-watcher.Events:
if filepath.Ext(event.Name) == extension {
f.readAgain()
}
case err = <-watcher.Errors:
f.params.ProcessLog.Warn("watching the rule files failed",
"error", err.Error())
}
}
}
// readAgain reads the rule files again, in place of the rules loaded, or
// logs the error that keeps the rules as they were.
func (f *Files) readAgain() {
rules, err := read(f.params.Dir)
if err != nil {
f.params.ProcessLog.Error(
"a rule file has an error, and the rules stay as they were",
"error", err.Error())
return
}
f.rules.Store(&rules)
f.logRead(len(rules))
}
// logRead logs that the rule files were read, and how many rules they
// hold, which can be none.
func (f *Files) logRead(count int) {
f.params.ProcessLog.Info("read the rule files",
"directory", f.params.Dir, "rules", count)
}
// read returns the rules of every rule file in dir, in the order of the
// files' names, and then of their lines.
func read(dir string) ([]Rule, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
}
var rules []Rule
// places are where each id is, as "<file>, line <n>".
places := map[string]string{}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != extension {
continue
}
rules, err = readFile(filepath.Join(dir, entry.Name()), rules, places)
if err != nil {
return nil, err
}
}
return rules, nil
}
// readFile appends the rules of the rule file at path to rules. places
// are where each id read so far is, and gain those of the file.
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
if err != nil {
return nil, err
}
number := 0
for line := range strings.Lines(string(data)) {
number++
place := fmt.Sprintf("%s, line %d", path, number)
text := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
rule, isRule, err := parse(text)
if err != nil {
return nil, fmt.Errorf("%s: %w", place, err)
}
if !isRule {
continue
}
first, used := places[rule.ID]
if used {
return nil, fmt.Errorf("%s: the id %q %w %s", place, rule.ID, errUsedTwice, first)
}
places[rule.ID] = place
rules = append(rules, rule)
}
return rules, nil
}
// parse reads a line of a rule file. It returns false for a blank line
// and for a comment, a line that starts with #.
func parse(line string) (Rule, bool, error) {
line = strings.TrimLeft(line, " \t")
if line == "" || strings.HasPrefix(line, "#") {
return Rule{}, false, nil
}
fields := ruleLine.FindStringSubmatch(line)
if fields == nil {
return Rule{}, false, errNotRule
}
rule := Rule{ID: fields[1], Target: fields[2], Action: fields[3]}
switch {
case !idChars.MatchString(rule.ID):
return Rule{}, false, fmt.Errorf("the id %q %w", rule.ID, errNotID)
case !isTarget(rule.Target):
return Rule{}, false, fmt.Errorf("the target %q %w", rule.Target, errNotTarget)
case !slices.Contains([]string{ActionLog, ActionBlock, ActionBan}, rule.Action):
return Rule{}, false, fmt.Errorf("the action %q %w", rule.Action, errNotAction)
}
regex, err := regexp.Compile(fields[4])
if err != nil {
return Rule{}, false, fmt.Errorf("the regex %w: %w", errNotRegex, err)
}
rule.regex = regex
return rule, true, nil
}
// isTarget reports whether target is one a rule may have.
func isTarget(target string) bool {
switch target {
case "path", "query", "uri", "method", "host", "user_agent", "referer":
return true
}
name, isHeader := strings.CutPrefix(target, headerTarget)
return isHeader && name != ""
}
// matches reports whether the rule's regex matches its target in r. For
// uri it is matched against the path and query as received, and against
// them once percent-decoded, so that an encoded probe cannot slip past.
func (rule Rule) matches(r *http.Request) bool {
if rule.Target == "uri" {
uri := pathAndQuery(r)
return rule.regex.MatchString(uri) || rule.regex.MatchString(decodeOnce(uri))
}
return rule.regex.MatchString(value(rule.Target, r))
}
// value returns what a rule with target, other than uri, is matched
// against in r: the path and the query as the client sent them, before
// any decoding or re-encoding, split at the first ?, and a header's values
// joined by ", ", as HTTP joins those of a header sent more than once.
func value(target string, r *http.Request) string {
switch target {
case "path":
path, _, _ := strings.Cut(pathAndQuery(r), "?")
return path
case "query":
_, query, _ := strings.Cut(pathAndQuery(r), "?")
return query
case "method":
return r.Method
case "host":
return r.Host
case "user_agent":
return header(r, "User-Agent")
case "referer":
return header(r, "Referer")
default:
return header(r, strings.TrimPrefix(target, headerTarget))
}
}
// pathAndQuery returns the path and the query of r as the client sent
// them: the target of its request line, r.RequestURI, of which a target
// in absolute form, http://host/path as a client sends it to a proxy,
// gives what follows the host. r.URL is not used: when the path holds a
// character it escapes, such as \ or a non-ASCII byte, it decodes the
// whole path and escapes it again, so that \ becomes %5C and %2e a dot.
func pathAndQuery(r *http.Request) string {
if !r.URL.IsAbs() {
return r.RequestURI
}
_, afterScheme, _ := strings.Cut(r.RequestURI, "://")
start := strings.IndexAny(afterScheme, "/?")
if start < 0 {
return ""
}
return afterScheme[start:]
}
// header returns the values of r's header name joined by ", ", or "" if
// r has no such header.
func header(r *http.Request, name string) string {
return strings.Join(r.Header.Values(name), ", ")
}
// decodeOnce returns s with each percent escape, such as %2e, replaced by
// the byte it stands for. A % that is not followed by two hex digits is
// left as it is, so that a malformed escape cannot keep the rest of s
// from being decoded.
func decodeOnce(s string) string {
var decoded strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+escapeLength <= len(s) {
b, err := hex.DecodeString(s[i+1 : i+escapeLength])
if err == nil {
decoded.Write(b)
i += escapeLength - 1
continue
}
}
decoded.WriteByte(s[i])
}
return decoded.String()
}
+595
View File
@@ -0,0 +1,595 @@
package rules_test
import (
"context"
"encoding/json"
"log/slog"
"maps"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strconv"
"testing"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
const (
// What the process log says once Watch watches the directory, after
// each reading of the rule files, and for one that has an error.
watching = "watching the rule files for edits"
read = "read the rule files"
hasError = "a rule file has an error, and the rules stay as they were"
// maxLogLines is how many lines of the process log wait for a test to
// read them.
maxLogLines = 64
// browser is the user agent of an ordinary visitor.
browser = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
// testFile is the rule file of a test that needs only one, and
// firstFile the first of a test's rule files.
testFile = "test.rules"
firstFile = "00-a.rules"
// userAgent is the header that carries the user agent.
userAgent = "User-Agent"
)
func TestEachTargetMatchesWhatItNames(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
rule string // its target, action and regex
uri string // the request's path and query
header http.Header
want bool
}{
{"path as received", `path log ^/%2eenv$`, "/%2eenv", nil, true},
{"path not decoded", `path log ^/\.env$`, "/%2eenv", nil, false},
{"path without the query", `path log ^/a$`, "/a?b=c", nil, true},
{"query as received", `query log ^b=%2e$`, "/a?b=%2e", nil, true},
{"uri as received", `uri log ^/a\?b=%2e$`, "/a?b=%2e", nil, true},
{"uri decoded", `uri log (\.\./){2}`, "/a?f=%2e%2e%2f%2e%2e%2f", nil, true},
{
"uri decoded past malformed escapes", `uri log (\.\./){2}&h=%$`,
"/a?g=%zz&f=%2e%2e%2f%2e%2e%2f&h=%", nil, true,
},
{"uri decoded only once", `uri log ^/a\.b$`, "/a%252eb", nil, false},
{"method", `method log ^PUT$`, "/", nil, true},
{"host", `host log ^app\.example$`, "/", nil, true},
{
"user_agent", `user_agent log ^sqlmap/`, "/",
http.Header{userAgent: {"sqlmap/1.8"}}, true,
},
{
"user_agent sent twice", `user_agent log ^curl/8, sqlmap/`, "/",
http.Header{userAgent: {"curl/8", "sqlmap/1.8"}}, true,
},
{"user_agent missing", `user_agent log ^$`, "/", nil, true},
{
"referer", `referer log ^https://spam\.example/`, "/",
http.Header{"Referer": {"https://spam.example/buy"}}, true,
},
{
"a header sent twice", `header:x-api-version log ^2, 3$`, "/",
http.Header{"X-Api-Version": {"2", "3"}}, true,
},
{"a header missing", `header:X-Api-Version log ^$`, "/", nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
files := load(t, ruleFiles{testFile: "a-rule " + tc.rule + "\n"})
// Every request is a PUT, which the method rule looks for.
r := httptest.NewRequestWithContext(t.Context(), http.MethodPut,
"http://app.example"+tc.uri, nil)
maps.Copy(r.Header, tc.header)
got := len(files.Match(r)) == 1
if got != tc.want {
t.Errorf("%s matches %s: %t, want %t", tc.rule, tc.uri, got, tc.want)
}
})
}
}
func TestPathMatchedAsTheClientSentIt(t *testing.T) {
t.Parallel()
// Each path holds a character Go's URL type would escape again, \ or
// a non-ASCII byte, and each rule is written for the path as sent.
for _, tc := range []struct {
rule string // its target, action and regex
sent string // the path and query the client sent
}{
{`path log ^/\.\.\\\.\.\\windows\\win\.ini$`, `/..\..\windows\win.ini`},
{`path log ^/%2e%2e\\%2e%2e\\windows\\win\.ini$`, `/%2e%2e\%2e%2e\windows\win.ini`},
{`path log ^/café$`, "/café?x=1"},
{`uri log ^/%2e%2e\\%2e%2e\\boot\.ini\?x=1$`, `/%2e%2e\%2e%2e\boot.ini?x=1`},
} {
files := load(t, ruleFiles{testFile: "as-sent " + tc.rule + "\n"})
// The target in origin form, as traefik sends it, and in absolute
// form, as a client sends it to a proxy.
for _, target := range []string{tc.sent, "http://app.example" + tc.sent} {
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
wantMatched(t, files, r, "as-sent")
}
}
}
func TestMatchingStopsAtTheFirstRuleThatRefuses(t *testing.T) {
t.Parallel()
files := load(t, ruleFiles{testFile: `
every-path path log ^/
no-path path log ^$
first-refusal path block ^/probe
later-ban path ban ^/probe
after path log ^/
`})
// Every log rule that matches is noted, and the block rule ends the
// matching.
wantMatched(t, files, get(t, "/probe"), "every-path", "first-refusal")
wantMatched(t, files, get(t, "/page"), "every-path", "after")
// A ban rule ends it too.
files = load(t, ruleFiles{testFile: "ban path ban ^/\nlater path block ^/\n"})
wantMatched(t, files, get(t, "/"), "ban")
}
func TestFilesReadInNameOrderThenLineOrder(t *testing.T) {
t.Parallel()
files := load(t, ruleFiles{
"50-b.rules": "b1 path log ^/\n\n# a comment\n # an indented one\nb2 path log ^/\n",
firstFile: "a1 path log ^/\r\n",
// None is a rule file.
"notes.txt": "notes, not rules\n",
"10-c.rules.bak": "an old copy\n",
"20-d.rules/keep": "a file in a directory\n",
})
wantMatched(t, files, get(t, "/"), "a1", "b1", "b2")
if files.Len() != 3 {
t.Errorf("%d rules loaded, want 3", files.Len())
}
}
func TestFaultStopsTheStartNamingTheFileAndLine(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
content string
line int
want string
}{
{
"too few fields", "env-file path ban\n", 1,
"is not a rule: an id, a target, an action and a regex, " +
"separated by spaces or tabs",
},
{
"an id of other characters", "# ids\n\nenv.file path ban ^/\n", 3,
`the id "env.file" is not an id of letters, digits, - and _`,
},
{
"an unknown target", "env-file paths ban ^/\n", 1,
`the target "paths" is not path, query, uri, method, host, ` +
"user_agent, referer or header:<Name>",
},
{
"a header without a name", "env-file header: ban ^/\n", 1,
`the target "header:" is not path, query, uri, method, host, ` +
"user_agent, referer or header:<Name>",
},
{
"an unknown action", "env-file path deny ^/\n", 1,
`the action "deny" is not log, block or ban`,
},
{
"a regex that does not compile", "env-file path ban ^/(\n", 1,
"the regex does not compile: error parsing regexp: " +
"missing closing ): `^/(`",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{"00-default.rules": tc.content})
path := filepath.Join(dir, "00-default.rules")
wantRefused(t, dir, path+", line "+strconv.Itoa(tc.line)+": "+tc.want)
})
}
}
func TestIDUsedTwiceStopsTheStartNamingBothPlaces(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{
"00-a.rules": "probe path log ^/a\n",
"50-b.rules": "other path log ^/b\nprobe path ban ^/c\n",
})
wantRefused(t, dir, filepath.Join(dir, "50-b.rules")+`, line 2: the id "probe" `+
"is already the id of the rule at "+filepath.Join(dir, "00-a.rules")+", line 1")
}
func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "rules.d")
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory")
}
func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
t.Parallel()
params, lines := newParams(writeFiles(t, ruleFiles{"00-default.rules": "# none\n"}))
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
line := lines.waitFor(t, read)
if files.Len() != 0 || line["rules"] != 0.0 {
t.Errorf("%d rules loaded, and the log says %v, want none", files.Len(), line)
}
}
func TestRuleFilesOffReadNothing(t *testing.T) {
t.Parallel()
// SWWAF_RULES_DIR does not exist, which would stop the start.
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
params.Enabled = false
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
if files.Len() != 0 || files.Match(get(t, "/")) != nil {
t.Errorf("%d rules loaded with the rule files off", files.Len())
}
// It would watch until the test ends.
files.Watch(t.Context())
}
func TestEditsTakenInWhileRunning(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
files, lines := watch(t, dir)
// matches reports whether path matches a rule.
matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 }
// A file added.
save(t, dir, "50-b.rules", "second path block ^/second\n")
lines.waitUntil(t, func() bool { return matches("/second") })
wantMatched(t, files, get(t, "/first"), "first")
// A file edited.
save(t, dir, firstFile, "first path block ^/edited\n")
lines.waitUntil(t, func() bool { return !matches("/first") })
wantMatched(t, files, get(t, "/edited"), "first")
// A file removed.
err := os.Remove(filepath.Join(dir, "50-b.rules"))
if err != nil {
t.Fatalf("remove: %v", err)
}
lines.waitUntil(t, func() bool { return !matches("/second") })
wantMatched(t, files, get(t, "/edited"), "first")
}
func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
t.Parallel()
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
files, lines := watch(t, dir)
// The edit's second line has an unknown action, so the rules stay as
// they were, the first line's earlier version included.
save(t, dir, firstFile, "first path block ^/edited\nsecond path bann ^/second\n")
line := lines.waitFor(t, hasError)
want := filepath.Join(dir, firstFile) +
`, line 2: the action "bann" is not log, block or ban`
if line["error"] != want || line["level"] != "ERROR" {
t.Errorf("logged %v, want an error %q", line, want)
}
wantMatched(t, files, get(t, "/first"), "first")
wantMatched(t, files, get(t, "/second"))
// Once mended, the file is read again.
save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
wantMatched(t, files, get(t, "/edited"), "first")
}
func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) {
t.Parallel()
params, _ := newParams(filepath.Join("..", "..", "share", "rules.d"))
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load the default file: %v", err)
}
// Probes sent by a browser, by the rule that refuses them.
for rule, targets := range map[string][]string{
"env-file": {"/.env", "/.env.production", "/.ENV"},
"vcs-dir": {"/.git/config", "/.git", "/.svn/entries"},
"secrets-dir": {"/.aws/credentials", "/.ssh/id_rsa"},
"secret-file": {"/.htpasswd", "/.DS_Store", "/.git-credentials"},
"editor-dir": {"/.vscode/sftp.json"},
"backup-file": {
"/wp-config.php.bak", "/index.php~", "/dump.sql", "/backup.sql.gz",
},
"log-file": {"/debug.log"},
"compose-file": {"/docker-compose.yml", "/compose.yaml"},
"php-shell": {"/shell.php"},
"path-traversal": {
"/static/../../etc/passwd", "/f?f=%2e%2e%2f%2e%2e%2fetc%2fpasswd",
},
} {
for _, target := range targets {
wantRefusedBy(t, files, target, browser, rule)
}
}
// Scanners, by their user agents.
for _, scanner := range []string{
"sqlmap/1.8.4#stable (https://sqlmap.org)",
"Mozilla/5.0 (compatible; Nuclei - Open-source project)",
} {
wantRefusedBy(t, files, "/", scanner, "scanner-agent")
}
// Ordinary requests to a code forge for files of those names deeper
// in its paths, and for other files at its root.
for _, target := range []string{
"/owner/repo/src/branch/main/.env.example",
"/owner/repo/src/branch/main/.env",
"/owner/repo/src/branch/main/.github/workflows/ci.yml",
"/owner/repo/src/branch/main/.vscode/settings.json",
"/owner/repo/src/branch/main/.htaccess",
"/owner/repo/src/branch/main/docker-compose.yml",
"/owner/repo/src/branch/main/db/schema.sql",
"/owner/repo/raw/branch/main/debug.log",
"/owner/repo.git/info/refs?service=git-upload-pack",
"/owner/repo/src/branch/main/docs/../README.md",
"/user/login?redirect_to=%2fowner%2frepo",
"/index.php",
"/.well-known/security.txt",
} {
r := get(t, target)
r.Header.Set(userAgent, browser)
matched := files.Match(r)
if len(matched) != 0 {
t.Errorf("%s matched %v, want no rule", target, ids(matched))
}
}
// A request without a user agent is only noted.
wantMatched(t, files, get(t, "/"), "empty-agent")
}
// ruleFiles are files to write into a directory of rule files, by name.
type ruleFiles map[string]string
// writeFiles writes files into a new directory, and returns it.
func writeFiles(t *testing.T, files ruleFiles) string {
t.Helper()
dir := t.TempDir()
for name, content := range files {
path := filepath.Join(dir, name)
err := os.MkdirAll(filepath.Dir(path), 0o700)
if err != nil {
t.Fatalf("mkdir: %v", err)
}
err = os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
return dir
}
// save writes content to the rule file name in dir as an editor that
// saves by renaming does, so that the file is never seen half written.
func save(t *testing.T, dir, name, content string) {
t.Helper()
path := filepath.Join(dir, name)
err := os.WriteFile(path+".tmp", []byte(content), 0o600)
if err != nil {
t.Fatalf("write %s: %v", name, err)
}
err = os.Rename(path+".tmp", path)
if err != nil {
t.Fatalf("rename: %v", err)
}
}
// newParams returns Params for the rule files in dir, switched on, with
// the process log in the processLog returned.
func newParams(dir string) (rules.Params, processLog) {
lines := make(processLog, maxLogLines)
return rules.Params{
Dir: dir,
Enabled: true,
ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)),
}, lines
}
// load writes files into a new directory and loads the rules in it.
func load(t *testing.T, files ruleFiles) *rules.Files {
t.Helper()
params, _ := newParams(writeFiles(t, files))
params.ProcessLog = slog.New(slog.DiscardHandler)
loaded, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
return loaded
}
// watch loads the rules in dir, runs their Watch until the test ends, and
// waits until it watches the directory.
func watch(t *testing.T, dir string) (*rules.Files, processLog) {
t.Helper()
params, lines := newParams(dir)
files, err := rules.Load(params)
if err != nil {
t.Fatalf("load: %v", err)
}
ctx, stop := context.WithCancel(t.Context())
stopped := make(chan struct{})
go func() {
files.Watch(ctx)
close(stopped)
}()
t.Cleanup(func() {
stop()
<-stopped
})
lines.waitFor(t, watching)
return files, lines
}
// wantRefused checks that loading the rule files in dir fails with the
// error want.
func wantRefused(t *testing.T, dir, want string) {
t.Helper()
params, _ := newParams(dir)
_, err := rules.Load(params)
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
}
// get returns a GET request for target, a path and an optional query, as
// smallwebwaf's server reads it, without a user agent.
func get(t *testing.T, target string) *http.Request {
t.Helper()
return httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"http://app.example"+target, nil)
}
// wantRefusedBy checks that a GET request for target with the user agent
// sent matches rule alone, and that rule refuses it.
func wantRefusedBy(t *testing.T, files *rules.Files, target, sent, rule string) {
t.Helper()
r := get(t, target)
r.Header.Set(userAgent, sent)
matched := files.Match(r)
if len(matched) != 1 || matched[0].ID != rule || matched[0].Action == rules.ActionLog {
t.Errorf("%s from %q matched %v, want %s alone, refusing it", target,
sent, ids(matched), rule)
}
}
// wantMatched checks the ids of the rules r matches, in order.
func wantMatched(t *testing.T, files *rules.Files, r *http.Request, want ...string) {
t.Helper()
got := ids(files.Match(r))
if !slices.Equal(got, want) {
t.Errorf("%s matched %v, want %v", r.URL, got, want)
}
}
// ids returns the ids of matched.
func ids(matched []rules.Rule) []string {
got := make([]string, 0, len(matched))
for _, rule := range matched {
got = append(got, rule.ID)
}
return got
}
// processLog receives the lines of a process log, each a JSON object, for
// a test to wait for.
type processLog chan string
// Write receives a line of the process log.
func (l processLog) Write(line []byte) (int, error) {
l <- string(line)
return len(line), nil
}
// waitFor returns the next line of the process log whose message is msg,
// passing over the lines before it. It waits as long as that takes, so
// that a slow test process cannot fail the test.
func (l processLog) waitFor(t *testing.T, msg string) map[string]any {
t.Helper()
for line := range l {
var fields map[string]any
err := json.Unmarshal([]byte(line), &fields)
if err != nil {
t.Fatalf("process log line %q is not JSON: %v", line, err)
}
if fields["msg"] == msg {
return fields
}
}
return nil
}
// waitUntil waits for the rule files to be read until done reports true,
// as it does once they have been read after the test's last change. One
// change can be seen more than once, and so read more than once.
func (l processLog) waitUntil(t *testing.T, done func() bool) {
t.Helper()
for !done() {
l.waitFor(t, read)
}
}