check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
690 lines
20 KiB
Go
690 lines
20 KiB
Go
package rules_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"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 decoded", `path log ^/\.env$`, "/%2Eenv", nil, true},
|
|
{"path decoded only once", `path log ^/\.env$`, "/%252eenv", nil, false},
|
|
{"path decoded without the query", `path log ^/a\?b$`, "/a%3fb?c", nil, true},
|
|
{"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, in absolute form,
|
|
// as a client sends it to a proxy, and with a scheme but no host,
|
|
// which Go reads as absolute form with no host, sending the app
|
|
// the path.
|
|
for _, target := range []string{
|
|
tc.sent, "http://app.example" + tc.sent, "http:" + tc.sent, "foo:" + 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 TestSpacesAndTabsEndingALineAreNotPartOfItsRegex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := load(t, ruleFiles{testFile: "env-file path block ^/\\.env$ \t \n"})
|
|
|
|
wantMatched(t, files, get(t, "/.env"), "env-file")
|
|
}
|
|
|
|
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 TestFileWhoseNameStartsWithADotIsNotARuleFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := writeFiles(t, ruleFiles{firstFile: "probe path block ^/probe\n"})
|
|
|
|
// The lock file Emacs makes beside a file while it is edited: a link to
|
|
// nothing, which cannot be read.
|
|
err := os.Symlink("user@host.1234:1700000000", filepath.Join(dir, ".#"+firstFile))
|
|
if err != nil {
|
|
t.Fatalf("symlink: %v", err)
|
|
}
|
|
|
|
params, _ := newParams(dir)
|
|
|
|
files, err := rules.Load(params)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
|
|
wantMatched(t, files, get(t, "/probe"), "probe")
|
|
}
|
|
|
|
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",
|
|
},
|
|
{
|
|
// Else its regex would be a space, found in nearly every user agent.
|
|
"a regex of only spaces and tabs", "scanner user_agent ban\t \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>",
|
|
},
|
|
{
|
|
"a header name written with its colon", "sqlmap header:User-Agent: ban sqlmap\n", 1,
|
|
`the target "header:User-Agent:" has a character after header: ` +
|
|
"that no header name can have",
|
|
},
|
|
{
|
|
"a header name with a semicolon", "accept header:Accept;q log ^$\n", 1,
|
|
`the target "header:Accept;q" has a character after header: ` +
|
|
"that no header name can have",
|
|
},
|
|
{
|
|
"a header name with brackets", "x-header header:X(y) log ^$\n", 1,
|
|
`the target "header:X(y)" has a character after header: ` +
|
|
"that no header name can have",
|
|
},
|
|
{
|
|
"the Host header", "host-header header:host block ^$\n", 1,
|
|
`the target "header:host" names a header that Go's HTTP server ` +
|
|
"takes out of every request, so a rule never sees it; " +
|
|
"the request's host is the target host",
|
|
},
|
|
{
|
|
"the Transfer-Encoding header",
|
|
"# bodies sent in chunks\nchunked header:Transfer-Encoding block ^chunked$\n", 2,
|
|
`the target "header:Transfer-Encoding" names a header that Go's ` +
|
|
"HTTP server takes out of every request, so a rule never sees it",
|
|
},
|
|
{
|
|
"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, "SWW_WAF_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()
|
|
|
|
// SWW_WAF_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, queue := 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)
|
|
}
|
|
|
|
// The error is raised as a file_error alert too, for the file.
|
|
wantFileError := func() {
|
|
t.Helper()
|
|
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError ||
|
|
waiting[0].Reason != hasError || waiting[0].Detail["error"] != want ||
|
|
waiting[0].Detail["file"] != filepath.Join(dir, firstFile) {
|
|
t.Errorf("alerts waiting %+v, want one file_error alert for %q", waiting, want)
|
|
}
|
|
}
|
|
wantFileError()
|
|
|
|
wantMatched(t, files, get(t, "/first"), "first")
|
|
wantMatched(t, files, get(t, "/second"))
|
|
|
|
// Once mended, the file is read again, and raises no alert.
|
|
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")
|
|
wantFileError()
|
|
}
|
|
|
|
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", "/%2eenv"},
|
|
"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, and the alerts waiting in a
|
|
// queue for a webhook that is never sent them.
|
|
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)),
|
|
Alerts: alerts.New(alerts.Params{
|
|
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
|
Events: alerts.Events(),
|
|
Cooldown: 15 * time.Minute,
|
|
Now: time.Now,
|
|
}),
|
|
}, 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. It returns the alerts' queue as
|
|
// well.
|
|
func watch(t *testing.T, dir string) (*rules.Files, processLog, *alerts.Queue) {
|
|
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, params.Alerts
|
|
}
|
|
|
|
// 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
|
|
// sww'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. They
|
|
// can be read before then too, as they are once Watch starts watching.
|
|
func (l processLog) waitUntil(t *testing.T, done func() bool) {
|
|
t.Helper()
|
|
|
|
for !done() {
|
|
l.waitFor(t, read)
|
|
}
|
|
}
|