diff --git a/coraza-spoa/Dockerfile b/coraza-spoa/Dockerfile index e5cafd7..4261155 100644 --- a/coraza-spoa/Dockerfile +++ b/coraza-spoa/Dockerfile @@ -27,6 +27,16 @@ RUN git clone --depth 1 --branch "${CORAZA_SPOA_VERSION}" \ && go mod download \ && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/coraza-spoa . +# Catalog extractor: walks the bundled CRS at build time and emits +# rules-catalog.json so WHP's UI can render rule metadata without parsing +# .conf files at runtime. Uses the SAME coraza-coreruleset version pin as +# the coraza-spoa binary above (drift between the two would mislabel rules). +FROM repo.anhonesthost.net/cloud-hosting-platform/golang:1.25 AS catalog +WORKDIR /src +COPY catalog-extractor/ . +RUN go build -trimpath -o /out/catalog-extractor . \ + && /out/catalog-extractor > /out/rules-catalog.json + # Distroless runtime: no shell, no package manager, no /tmp by default — # smallest attack surface for an exposed service. Audit log directory is # bind-mounted; coraza-spoa writes to it via direct file I/O (no shell needed). @@ -41,6 +51,7 @@ COPY config.yaml /etc/coraza-spoa/config.yaml COPY overrides.conf /etc/coraza/overrides.conf COPY local-overrides.conf /etc/coraza/local-overrides.conf COPY host-exceptions/ /etc/coraza/host-exceptions/ +COPY --from=catalog /out/rules-catalog.json /etc/coraza/rules-catalog.json # Audit log directory — bind-mount /var/log/coraza:/var/log/coraza from host # so logs persist across container restarts and AI Monitor can tail them. diff --git a/coraza-spoa/catalog-extractor/go.mod b/coraza-spoa/catalog-extractor/go.mod new file mode 100644 index 0000000..2d8bb97 --- /dev/null +++ b/coraza-spoa/catalog-extractor/go.mod @@ -0,0 +1,7 @@ +module catalog-extractor + +go 1.25 + +require github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 + +require github.com/magefile/mage v1.17.0 // indirect diff --git a/coraza-spoa/catalog-extractor/go.sum b/coraza-spoa/catalog-extractor/go.sum new file mode 100644 index 0000000..0c94447 --- /dev/null +++ b/coraza-spoa/catalog-extractor/go.sum @@ -0,0 +1,12 @@ +github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 h1:tqFO1lfVpTiyWtlN618OXpZMfw+nnN0Q4///W5W+/HM= +github.com/corazawaf/coraza-coreruleset/v4 v4.25.0/go.mod h1:nRuGXITxOPvsLF2VxaTB7pYok8QB8BitX3ZenXcUryY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= +github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/coraza-spoa/catalog-extractor/main.go b/coraza-spoa/catalog-extractor/main.go new file mode 100644 index 0000000..cc305db --- /dev/null +++ b/coraza-spoa/catalog-extractor/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "regexp" + "strconv" + "strings" + + crs "github.com/corazawaf/coraza-coreruleset/v4" +) + +type Rule struct { + ID int `json:"id"` + Msg string `json:"msg"` + Severity string `json:"severity"` + Tags []string `json:"tags"` + File string `json:"file"` +} + +var ( + idRe = regexp.MustCompile(`(?i)\bid:'?(\d+)'?`) + msgRe = regexp.MustCompile(`(?i)\bmsg:'([^']+)'`) + severityRe = regexp.MustCompile(`(?i)\bseverity:'?([A-Z]+)'?`) + tagRe = regexp.MustCompile(`(?i)\btag:'([^']+)'`) +) + +func main() { + out := []Rule{} + err := fs.WalkDir(crs.FS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if !strings.HasSuffix(path, ".conf") { + return nil + } + b, err := fs.ReadFile(crs.FS, path) + if err != nil { + return err + } + // Coalesce backslash-continuation lines so id/msg/etc on the same + // logical rule are visible to the per-line scanner. + text := regexp.MustCompile(`\\\s*\n\s*`).ReplaceAllString(string(b), " ") + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "SecRule") && !strings.HasPrefix(line, "SecAction") { + continue + } + m := idRe.FindStringSubmatch(line) + if m == nil { + continue + } + id, _ := strconv.Atoi(m[1]) + r := Rule{ID: id, File: path} + if mm := msgRe.FindStringSubmatch(line); mm != nil { + r.Msg = mm[1] + } + if mm := severityRe.FindStringSubmatch(line); mm != nil { + r.Severity = strings.ToLower(mm[1]) + } + for _, mm := range tagRe.FindAllStringSubmatch(line, -1) { + r.Tags = append(r.Tags, mm[1]) + } + out = append(out, r) + } + return nil + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +}