coraza: ship rules-catalog.json generated from bundled CRS at build time
All checks were successful
Build and push coraza-spoa / Build-and-Push (push) Successful in 44s
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 52s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 06:57:42 -07:00
parent b2adcdbed9
commit 489290ed33
4 changed files with 110 additions and 0 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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=

View File

@@ -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)
}
}