coraza: ship rules-catalog.json generated from bundled CRS at build time
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
7
coraza-spoa/catalog-extractor/go.mod
Normal file
7
coraza-spoa/catalog-extractor/go.mod
Normal 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
|
||||
12
coraza-spoa/catalog-extractor/go.sum
Normal file
12
coraza-spoa/catalog-extractor/go.sum
Normal 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=
|
||||
80
coraza-spoa/catalog-extractor/main.go
Normal file
80
coraza-spoa/catalog-extractor/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user