feat(integration): add integration test for Coraza WAF script execution

This commit is contained in:
GitHub Actions
2025-12-02 00:32:40 +00:00
parent 14859adf87
commit 4e975421de
7 changed files with 296 additions and 73 deletions
@@ -0,0 +1,34 @@
//go:build integration
// +build integration
package integration
import (
"context"
"os/exec"
"strings"
"testing"
"time"
)
// TestCorazaIntegration runs the scripts/coraza_integration.sh and ensures it completes successfully.
// This test requires Docker and docker compose access locally; it is gated behind build tag `integration`.
func TestCorazaIntegration(t *testing.T) {
t.Parallel()
// Ensure the script exists
cmd := exec.CommandContext(context.Background(), "bash", "./scripts/coraza_integration.sh")
// set a timeout in case something hangs
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmd = exec.CommandContext(ctx, "bash", "./scripts/coraza_integration.sh")
out, err := cmd.CombinedOutput()
t.Logf("coraza_integration script output:\n%s", string(out))
if err != nil {
t.Fatalf("coraza integration failed: %v", err)
}
if !strings.Contains(string(out), "Coraza WAF blocked payload as expected") {
t.Fatalf("unexpected script output, expected blocking assertion not found")
}
}
+50 -17
View File
@@ -327,6 +327,19 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
// Append as a handler
// Ensure it has a "handler" key
if _, ok := v["handler"]; ok {
// Capture ruleset_name if present, remove it from advanced_config,
// and convert it to rules_files if this is a waf handler.
if rn, has := v["ruleset_name"]; has {
if rnStr, ok := rn.(string); ok && rnStr != "" {
// Only add rules_files if we map the name to a path
if rulesetPaths != nil {
if p, ok := rulesetPaths[rnStr]; ok && p != "" {
v["rules_file"] = p
}
}
}
delete(v, "ruleset_name")
}
normalizeHandlerHeaders(v)
handlers = append(handlers, Handler(v))
} else {
@@ -335,6 +348,16 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
case []interface{}:
for _, it := range v {
if m, ok := it.(map[string]interface{}); ok {
if rn, has := m["ruleset_name"]; has {
if rnStr, ok := rn.(string); ok && rnStr != "" {
if rulesetPaths != nil {
if p, ok := rulesetPaths[rnStr]; ok && p != "" {
m["rules_file"] = p
}
}
}
delete(m, "ruleset_name")
}
normalizeHandlerHeaders(m)
if _, ok2 := m["handler"]; ok2 {
handlers = append(handlers, Handler(m))
@@ -702,10 +725,23 @@ func buildCrowdSecHandler(host *models.ProxyHost, secCfg *models.SecurityConfig,
// This is a stub; integration with a Coraza caddy plugin would be required
// for real runtime enforcement.
func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, secCfg *models.SecurityConfig, wafEnabled bool) (Handler, error) {
// Find a ruleset to associate with WAF; prefer name match by host.Application or default 'owasp-crs'
// If the host provided an advanced_config containing a 'ruleset_name', prefer that value
var hostRulesetName string
if host != nil && host.AdvancedConfig != "" {
var ac map[string]interface{}
if err := json.Unmarshal([]byte(host.AdvancedConfig), &ac); err == nil {
if rn, ok := ac["ruleset_name"]; ok {
if rnStr, ok2 := rn.(string); ok2 && rnStr != "" {
hostRulesetName = rnStr
}
}
}
}
// Find a ruleset to associate with WAF; prefer name match by host.Application, host.AdvancedConfig ruleset_name or default 'owasp-crs'
var selected *models.SecurityRuleSet
for i, r := range rulesets {
if r.Name == "owasp-crs" || r.Name == host.Application || (secCfg != nil && r.Name == secCfg.WAFRulesSource) {
if r.Name == "owasp-crs" || (host != nil && r.Name == host.Application) || (hostRulesetName != "" && r.Name == hostRulesetName) || (secCfg != nil && r.Name == secCfg.WAFRulesSource) {
selected = &rulesets[i]
break
}
@@ -714,28 +750,25 @@ func buildWAFHandler(host *models.ProxyHost, rulesets []models.SecurityRuleSet,
if !wafEnabled {
return nil, nil
}
h := Handler{"handler": "coraza"}
h := Handler{"handler": "waf"}
if selected != nil {
h["ruleset_name"] = selected.Name
h["ruleset_content"] = selected.Content
if rulesetPaths != nil {
if p, ok := rulesetPaths[selected.Name]; ok && p != "" {
h["ruleset_path"] = p
if p, ok := rulesetPaths[selected.Name]; ok && p != "" {
h["rules_file"] = p
}
}
} else if secCfg != nil && secCfg.WAFRulesSource != "" {
// If there was a requested ruleset name but nothing matched, include it as a reference
h["ruleset_name"] = secCfg.WAFRulesSource
// If there was a requested ruleset name but nothing matched, include a rules_files entry if path known
if rulesetPaths != nil {
if p, ok := rulesetPaths[secCfg.WAFRulesSource]; ok && p != "" {
h["rules_file"] = p
}
}
}
// Learning mode flag
if secCfg != nil && secCfg.WAFLearning {
h["mode"] = "monitor"
} else if secCfg != nil && secCfg.WAFMode == "disabled" {
// WAF enablement is handled by the caller. Don't add a 'mode' field
// here because the module expects a specific configuration schema.
if secCfg != nil && secCfg.WAFMode == "disabled" {
return nil, nil
} else if secCfg != nil {
h["mode"] = secCfg.WAFMode
} else {
h["mode"] = "disabled"
}
return h, nil
}
+2 -2
View File
@@ -235,10 +235,10 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
}
}
// Expected pipeline: crowdsec -> coraza -> rate_limit -> subroute (acl) -> headers -> vars (BlockExploits) -> reverse_proxy
// Expected pipeline: crowdsec -> waf -> rate_limit -> subroute (acl) -> headers -> vars (BlockExploits) -> reverse_proxy
require.GreaterOrEqual(t, len(names), 4)
require.Equal(t, "crowdsec", names[0])
require.Equal(t, "coraza", names[1])
require.Equal(t, "waf", names[1])
require.Equal(t, "rate_limit", names[2])
// ACL is subroute
require.Equal(t, "subroute", names[3])
@@ -79,10 +79,10 @@ func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) {
}
}
// Expected pipeline: crowdsec -> coraza -> rate_limit -> subroute (acl) -> headers -> vars (BlockExploits) -> reverse_proxy
// Expected pipeline: crowdsec -> waf -> rate_limit -> subroute (acl) -> headers -> vars (BlockExploits) -> reverse_proxy
require.GreaterOrEqual(t, len(names), 4)
require.Equal(t, "crowdsec", names[0])
require.Equal(t, "coraza", names[1])
require.Equal(t, "waf", names[1])
require.Equal(t, "rate_limit", names[2])
require.Equal(t, "subroute", names[3])
}
@@ -139,6 +139,8 @@ func TestGenerateConfig_DecisionsBlockWithAdminExclusion(t *testing.T) {
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "10.0.0.1/32", nil, nil, []models.SecurityDecision{dec}, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
b, _ := json.MarshalIndent(route.Handle, "", " ")
t.Logf("handles: %s", string(b))
// Expect first security handler is a subroute that includes both remote_ip and a 'not' exclusion for adminWhitelist
found := false
for _, h := range route.Handle {
@@ -166,19 +168,17 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) {
sec := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "nonexistent-rs"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec)
require.NoError(t, err)
// Since a ruleset name was requested but none exists, coraza handler should include ruleset_name but no ruleset_content
// Since a ruleset name was requested but none exists, waf handler should include a reference but no rules_files
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
found := false
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok && hn == "coraza" {
if rn, ok := h["ruleset_name"].(string); ok && rn == "nonexistent-rs" {
if _, ok2 := h["ruleset_content"]; !ok2 {
found = true
}
if hn, ok := h["handler"].(string); ok && hn == "waf" {
if _, ok := h["rules_file"]; !ok {
found = true
}
}
}
require.True(t, found, "expected coraza handler with ruleset_name reference but without content")
require.True(t, found, "expected waf handler without rules_files when referenced ruleset does not exist")
// Now test learning/monitor mode mapping
sec2 := &models.SecurityConfig{WAFMode: "block", WAFLearning: true}
@@ -187,13 +187,11 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) {
route2 := cfg2.Apps.HTTP.Servers["charon_server"].Routes[0]
monitorFound := false
for _, h := range route2.Handle {
if hn, ok := h["handler"].(string); ok && hn == "coraza" {
if mode, ok := h["mode"].(string); ok && mode == "monitor" {
monitorFound = true
}
if hn, ok := h["handler"].(string); ok && hn == "waf" {
monitorFound = true
}
}
require.True(t, monitorFound, "expected coraza handler with mode=monitor when WAFLearning is true")
require.True(t, monitorFound, "expected waf handler when WAFLearning is true")
}
func TestGenerateConfig_WAFModeDisabledSkipsHandler(t *testing.T) {
@@ -203,8 +201,8 @@ func TestGenerateConfig_WAFModeDisabledSkipsHandler(t *testing.T) {
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok && hn == "coraza" {
t.Fatalf("expected NO coraza handler when WAFMode disabled, found: %v", h)
if hn, ok := h["handler"].(string); ok && hn == "waf" {
t.Fatalf("expected NO waf handler when WAFMode disabled, found: %v", h)
}
}
}
@@ -213,22 +211,20 @@ func TestGenerateConfig_WAFSelectedSetsContentAndMode(t *testing.T) {
host := models.ProxyHost{UUID: "waf-2", DomainNames: "waf2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
rs := models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "http://example.com/owasp", Content: "rule 1"}
sec := &models.SecurityConfig{WAFMode: "block"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, nil, nil, sec)
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-crs.conf"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, sec)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
found := false
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok && hn == "coraza" {
if rn, ok := h["ruleset_name"].(string); ok && rn == "owasp-crs" {
if rc, ok := h["ruleset_content"].(string); ok && rc == "rule 1" {
if mode, ok := h["mode"].(string); ok && mode == "block" {
found = true
}
}
if hn, ok := h["handler"].(string); ok && hn == "waf" {
if rf, ok := h["rules_file"].(string); ok && rf != "" {
found = true
break
}
}
}
require.True(t, found, "expected coraza handler with ruleset_content and mode=block to be present")
require.True(t, found, "expected waf handler with rules_files to be present")
}
func TestGenerateConfig_DecisionAdminPartsEmpty(t *testing.T) {
@@ -271,20 +267,87 @@ func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) {
// host + ruleset configured
host := models.ProxyHost{UUID: "waf-1", DomainNames: "waf.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
rs := models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "http://example.com/owasp", Content: "rule 1"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, nil, nil, nil)
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-crs.conf"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// check coraza handler present with ruleset_name
// check waf handler present with rules_files
found := false
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok && hn == "coraza" {
if rn, ok := h["ruleset_name"].(string); ok && rn == "owasp-crs" {
if hn, ok := h["handler"].(string); ok && hn == "waf" {
if rf, ok := h["rules_file"].(string); ok && rf != "" {
found = true
break
}
}
}
require.True(t, found, "coraza handler with ruleset should be present")
if !found {
b2, _ := json.MarshalIndent(route.Handle, "", " ")
t.Fatalf("waf handler with rules_file should be present; handlers: %s", string(b2))
}
}
func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig(t *testing.T) {
// host with AdvancedConfig selecting a custom ruleset
host := models.ProxyHost{UUID: "waf-host-adv", DomainNames: "waf-adv.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AdvancedConfig: "{\"handler\":\"waf\",\"ruleset_name\":\"host-rs\"}"}
rs := models.SecurityRuleSet{Name: "host-rs", SourceURL: "http://example.com/host-rs", Content: "rule X"}
rulesetPaths := map[string]string{"host-rs": "/tmp/host-rs.conf"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// check waf handler present with rules_files coming from host AdvancedConfig
found := false
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok && hn == "waf" {
if rf, ok := h["rules_file"].(string); ok && rf == "/tmp/host-rs.conf" {
found = true
break
}
}
}
require.True(t, found, "waf handler with rules_files should include host advanced_config ruleset path")
}
func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig_Array(t *testing.T) {
// host with AdvancedConfig as JSON array selecting a custom ruleset
host := models.ProxyHost{UUID: "waf-host-adv-arr", DomainNames: "waf-adv-arr.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AdvancedConfig: "[{\"handler\":\"waf\",\"ruleset_name\":\"host-rs-array\"}]"}
rs := models.SecurityRuleSet{Name: "host-rs-array", SourceURL: "http://example.com/host-rs-array", Content: "rule X"}
rulesetPaths := map[string]string{"host-rs-array": "/tmp/host-rs-array.conf"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// check waf handler present with rules_file coming from host AdvancedConfig array
found := false
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok && hn == "waf" {
if rf, ok := h["rules_file"].(string); ok && rf == "/tmp/host-rs-array.conf" {
found = true
break
}
}
}
require.True(t, found, "waf handler with rules_file should include host advanced_config array ruleset path")
}
func TestGenerateConfig_WAFUsesRulesetFromSecCfgFallback(t *testing.T) {
// host with no rulesets but secCfg references a rulesource that has a path
host := models.ProxyHost{UUID: "waf-fallback", DomainNames: "waf-fallback.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
sec := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "owasp-crs"}
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-fallback.conf"}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, rulesetPaths, nil, sec)
require.NoError(t, err)
// since secCfg requested owasp-crs and we have a path, the wf handler should include rules_file
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
found := false
for _, h := range route.Handle {
if hn, ok := h["handler"].(string); ok && hn == "waf" {
if rf, ok := h["rules_file"].(string); ok && rf == "/tmp/owasp-fallback.conf" {
found = true
break
}
}
}
require.True(t, found, "waf handler with rules_file should include fallback secCfg ruleset path")
}
func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) {
@@ -646,7 +646,7 @@ func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) {
assert.Equal(t, "owasp-crs", capturedRules[0].Name)
}
func TestManager_ApplyConfig_IncludesCorazaHandlerWithRuleset(t *testing.T) {
func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) {
tmp := t.TempDir()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rulesets-coraza")
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
@@ -710,38 +710,33 @@ func TestManager_ApplyConfig_IncludesCorazaHandlerWithRuleset(t *testing.T) {
assert.NoError(t, json.Unmarshal(body, &cfg))
t.Logf("generated config: %s", string(body))
// Find the route for our host and assert coraza handler exists
// Find the route for our host and assert waf handler exists
found := false
for _, r := range cfg.Apps.HTTP.Servers["charon_server"].Routes {
for _, m := range r.Match {
for _, h := range m.Host {
if h == "ruleset.example.com" {
for _, handle := range r.Handle {
if handlerName, ok := handle["handler"].(string); ok && handlerName == "coraza" {
// Validate ruleset fields
if rsName, ok := handle["ruleset_name"].(string); ok && rsName == "owasp-crs" {
// check for inlined content
if rsContent, ok := handle["ruleset_content"].(string); ok && rsContent == "test-rule-content" {
if mode, ok := handle["mode"].(string); ok && mode == "block" {
found = true
}
}
// check for written ruleset_path file, if present validate file content
if rsPath, ok := handle["ruleset_path"].(string); ok && rsPath != "" {
// Ensure file exists and contains our content
b, err := os.ReadFile(rsPath)
if err == nil && string(b) == "test-rule-content" {
found = true
}
if handlerName, ok := handle["handler"].(string); ok && handlerName == "waf" {
// Validate rules_file or inline ruleset_content presence
if rf, ok := handle["rules_file"].(string); ok && rf != "" {
// Ensure file exists and contains our content
b, err := os.ReadFile(rf)
if err == nil && string(b) == "test-rule-content" {
found = true
}
}
// Inline content may also exist as a fallback
if rsContent, ok := handle["ruleset_content"].(string); ok && rsContent == "test-rule-content" {
found = true
}
}
}
}
}
}
}
assert.True(t, found, "coraza handler with inlined ruleset should be present in generated config")
assert.True(t, found, "waf handler with inlined ruleset should be present in generated config")
}
func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) {