diff --git a/Example.md b/Example.md new file mode 100644 index 0000000..7524dee --- /dev/null +++ b/Example.md @@ -0,0 +1,229 @@ +# Coraza WAF Example + +Check out the [ansibleguy.infra_haproxy Example](https://github.com/ansibleguy/infra_haproxy/blob/latest/ExampleCorazaWAF.md) for a full integration example! + +## Config + +```yaml +waf: + apps: + - name: 'default' + - name: 'default_block' + block: true + + # apis + - name: 'app1' + block: true + rules: + vars: + tx.allowed_methods: 'GET HEAD POST PUT OPTIONS' + + rule_changes: + 'REQUEST-933-APPLICATION-ATTACK-PHP.conf': false + '`REQUEST-944-APPLICATION-ATTACK-JAVA.conf`': + 944100: false + + 'REQUEST-941-APPLICATION-ATTACK-XSS.conf': + 941010: | + SecRule REQUEST_FILENAME "!@validateByteRange 20, 45-47, 48-57, 65-90, 95, 97-122" \ + "id:941010,\ + phase:1,\ + pass,\ + t:none,\ + nolog,\ + tag:'OWASP_CRS',\ + ctl:ruleRemoveTargetByTag=xss-perf-disable;REQUEST_FILENAME,\ + ver:'OWASP_CRS/4.7.0'" + # TEST + + + - name: 'app2' +``` + +---- + +## Result + +```bash +root@test-ag-haproxy-waf:/# cat /etc/haproxy/haproxy.cfg +> # Ansible managed: Do NOT edit this file manually! +> # ansibleguy.infra_haproxy +> +> global +> daemon +> user haproxy +> group haproxy +> +> tune.ssl.capture-buffer-size 96 +> +> log /dev/log local0 +> log /dev/log local1 notice +> chroot /var/lib/haproxy +> stats socket /run/haproxy/admin.sock mode 660 level admin +> stats timeout 30s +> ca-base /etc/ssl/certs +> crt-base /etc/ssl/private +> ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 +> ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 +> ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets +> +> defaults +> log global +> mode http +> option httplog +> option dontlognull +> timeout connect 5000 +> timeout client 50000 +> timeout server 50000 +> errorfile 400 /etc/haproxy/errors/400.http +> errorfile 403 /etc/haproxy/errors/403.http +> errorfile 408 /etc/haproxy/errors/408.http +> errorfile 500 /etc/haproxy/errors/500.http +> errorfile 502 /etc/haproxy/errors/502.http +> errorfile 503 /etc/haproxy/errors/503.http +> errorfile 504 /etc/haproxy/errors/504.http + +root@test-ag-haproxy-waf:/# cat /etc/haproxy/waf-coraza.cfg +> # Ansible managed +> # ansibleguy.haproxy_waf_coraza +> +> backend coraza-waf-spoa +> mode tcp +> server coraza-waf 127.0.0.1:9000 check + +root@test-ag-haproxy-waf:/# cat waf-coraza-spoe.cfg +> # Ansible managed +> # ansibleguy.haproxy_waf_coraza +> +> [coraza] +> spoe-agent coraza-agent +> messages coraza-req +> groups coraza-req +> option var-prefix coraza +> option set-on-error error +> timeout hello 2s +> timeout idle 2m +> timeout processing 500ms +> use-backend coraza-waf-spoa +> log global +> +> spoe-message coraza-req +> args app=var(txn.waf_app) src-ip=src src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body +> +> spoe-group coraza-req +> messages coraza-req + +root@test-ag-haproxy-waf:/# cat /etc/haproxy/conf.d/frontend.cfg +> # Ansible managed: Do NOT edit this file manually! +> # ansibleguy.infra_haproxy +> +> frontend fe_web +> mode http +> bind [::]:80 v4v6 +> +> ... +> http-request set-var(txn.waf_app) str(default_block) if be_test2_filter_domains +> ... +> http-request set-var(txn.waf_app) str(be_app1) if be_app1_filter_domains +> ... +> http-request set-var(txn.waf_app) str(be_app2) if be_app2_filter_domains +> +> # Coraza WAF +> http-request set-var(txn.waf_app) str(default) if !{ var(txn.waf_app) -m found } +> +> filter spoe engine coraza config /etc/haproxy/waf-coraza-spoe.cfg +> http-request send-spoe-group coraza coraza-req +> http-request capture var(txn.waf_app) len 50 +> http-request capture var(txn.coraza.id) len 16 +> http-request capture var(txn.coraza.error) len 1 +> http-request capture var(txn.coraza.action) len 8 +> http-request deny status 403 default-errorfiles if { var(txn.coraza.action) -m str deny } +> http-response deny status 403 default-errorfiles if { var(txn.coraza.action) -m str deny } +> http-request silent-drop if { var(txn.coraza.action) -m str drop } +> http-response silent-drop if { var(txn.coraza.action) -m str drop } + +root@test-ag-haproxy-waf:/# systemctl status haproxy.service +> * haproxy.service - HAProxy Load Balancer +> Loaded: loaded (/lib/systemd/system/haproxy.service; enabled; preset: enabled) +> Drop-In: /etc/systemd/system/haproxy.service.d +> `-override.conf +> Active: active (running) since Sat 2024-05-04 16:24:54 UTC; 4min 11s ago +> Docs: man:haproxy(1) +> file:/usr/share/doc/haproxy/configuration.txt.gz +> https://www.haproxy.com/documentation/haproxy-configuration-manual/latest/ +> https://github.com/ansibleguy/infra_haproxy +> Process: 4574 ExecStartPre=/usr/sbin/haproxy -c -f $CONFIG -f /etc/haproxy/conf.d/ -f /etc/haproxy/waf-coraza.cfg (code=exited, status=0/SUCCESS) +> Process: 4635 ExecReload=/usr/sbin/haproxy -c -f $CONFIG -f /etc/haproxy/conf.d/ -f /etc/haproxy/waf-coraza.cfg (code=exited, status=0/SUCCESS) +> Process: 4637 ExecReload=/bin/kill -USR2 $MAINPID (code=exited, status=0/SUCCESS) +> Main PID: 4576 (haproxy) +> Status: "Ready." +> Tasks: 7 (limit: 1783) +> Memory: 132.2M +> CPU: 297ms +> CGroup: /system.slice/haproxy.service +> |-4576 /usr/sbin/haproxy -Ws -f /etc/haproxy/haproxy.cfg -f /etc/haproxy/conf.d/ -p /run/haproxy.pid -S /run/haproxy-master.sock +> `-4639 /usr/sbin/haproxy -sf 4578 -x sockpair@4 -Ws -f /etc/haproxy/haproxy.cfg -f /etc/haproxy/conf.d/ -p /run/haproxy.pid -S /run/haproxy-master.sock + +root@test-ag-haproxy-waf:/# systemctl status coraza-spoa.service +> ● coraza-spoa.service - Coraza WAF SPOA Daemon +> Loaded: loaded (/etc/systemd/system/coraza-spoa.service; enabled; preset: enabled) +> Drop-In: /etc/systemd/system/coraza-spoa.service.d +> └─override.conf +> Active: active (running) since Fri 2024-12-27 19:45:29 CET; 1h 12min ago +> Docs: https://www.coraza.io +> https://github.com/corazawaf/coraza-spoa +> https://github.com/corazawaf/coraza +> https://coraza.io/docs/seclang/directives/ +> https://github.com/ansibleguy/haproxy_waf_coraza +> https://docs.o-x-l.com/waf/coraza.html +> Main PID: 3878168 (coraza-spoa) +> Tasks: 10 (limit: 4531) +> Memory: 11.7M +> CPU: 4.099s +> CGroup: /system.slice/coraza-spoa.service +> └─3878168 /usr/bin/coraza-spoa -config=/etc/coraza-spoa/spoa.yml + +root@test-ag-haproxy-waf:/# ls -l /etc/coraza-spoa/apps/be_log_ui/v4.7.0/@owasp_crs/*PHP* +> -rwxr-x--- 1 root coraza 17126 Dec 28 22:36 /etc/coraza-spoa/apps/app1/v4.7.0/@owasp_crs/REQUEST-933-APPLICATION-ATTACK-PHP.conf.disabled +> -rwxr-x--- 1 root coraza 4487 Dec 27 15:55 /etc/coraza-spoa/apps/app1/v4.7.0/@owasp_crs/RESPONSE-953-DATA-LEAKAGES-PHP.conf + +root@test-ag-haproxy-waf:/# cat /etc/coraza-spoa/apps/app1/v4.7.0/@owasp_crs/REQUEST-944-APPLICATION-ATTACK-JAVA.conf +> ... +> SecRule TX:DETECTION_PARANOIA_LEVEL "@lt 1" "id:944012,phase:2,pass,nolog,tag:'OWASP_CRS',ver:'OWASP_CRS/4.7.0',skipAfter:END-REQUEST-944-APPLICATION-ATTACK-JAVA" +> #SecRule ARGS|ARGS_NAMES|REQUEST_COOKIES|!REQUEST_COOKIES:/__utm/|REQUEST_COOKIES_NAMES|REQUEST_BODY|REQUEST_HEADERS|XML:/*|XML://@* \ +> # "@rx java\.lang\.(?:runtime|processbuilder)" \ +> # "id:944100,\ +> # phase:2,\ +> # block,\ +> # t:none,t:lowercase,\ +> # msg:'Remote Command Execution: Suspicious Java class detected',\ +> # logdata:'Matched Data: %{MATCHED_VAR} found within %{MATCHED_VAR_NAME}',\ +> # tag:'application-multi',\ +> # tag:'language-java',\ +> # tag:'platform-multi',\ +> # tag:'attack-rce',\ +> # tag:'paranoia-level/1',\ +> # tag:'OWASP_CRS',\ +> # tag:'capec/1000/152/137/6',\ +> # tag:'PCI/6.5.2',\ +> # ver:'OWASP_CRS/4.7.0',\ +> # severity:'CRITICAL',\ +> # setvar:'tx.rce_score=+%{tx.critical_anomaly_score}',\ +> # setvar:'tx.inbound_anomaly_score_pl1=+%{tx.critical_anomaly_score}'" +> SecRule ... +> ... + +root@test-ag-haproxy-waf:/# cat /etc/coraza-spoa/apps/app1/v4.7.0/@owasp_crs/REQUEST-941-APPLICATION-ATTACK-XSS.conf +> ... +> SecRule REQUEST_FILENAME "!@validateByteRange 20, 45-47, 48-57, 65-90, 95, 97-122" \ +> "id:941010,\ +> phase:1,\ +> pass,\ +> t:none,\ +> nolog,\ +> tag:'OWASP_CRS',\ +> ctl:ruleRemoveTargetByTag=xss-perf-disable;REQUEST_FILENAME,\ +> ver:'OWASP_CRS/4.7.0'" +> # TEST +> ... +``` diff --git a/README.md b/README.md index f51f0e0..cc43333 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,15 @@ ansible-galaxy install ansibleguy.haproxy_waf_coraza --roles-path ./roles ## Usage +### Example + +Here some detailed config example and its results: + +* [Example](https://github.com/ansibleguy/haproxy_waf_coraza/blob/latest/Example.md) + ### Config -**Minimal example** +**Example** ```yaml waf: @@ -66,8 +72,38 @@ waf: - name: 'be_app1' block: true + + rules: + # override vars inside CoreRuleset config REQUEST-901-INITIALIZATION.conf + vars: + tx.allowed_methods: 'GET HEAD POST PUT OPTIONS' + + rule_changes: + # disable PHP-checks + 'REQUEST-933-APPLICATION-ATTACK-PHP.conf': false + + # re-enable it + # 'REQUEST-933-APPLICATION-ATTACK-PHP.conf': true + + # change/update single rules + 'REQUEST-944-APPLICATION-ATTACK-JAVA.conf': + # disable (comment-out) single rule + 944100: false + + # re-enable it + # 944100: true + + # replace a rule with custom content + 944140: | + SecRule ... \ + "id:944140, ..." + ``` +---- + +### HAProxy Integration + Then you will need to include the SPOE-backend: `/etc/haproxy/waf-coraza.cfg` And target the SPOE-agents in your HAProxy config: (or use the role [ansibleguy/infra_haproxy](https://github.com/ansibleguy/infra_haproxy) with `haproxy.waf.coraza.enable=true`) @@ -239,6 +275,12 @@ There are also some useful **tags** available: * config => only update config * rules => only update rules +You can also use the `only_app` runtime-variable to only provision one WAF-App: + +```bash +ansible-playbook ... -e only_app=app1 --tags rules +``` + To debug errors - you can set the 'debug' variable at runtime: ```bash ansible-playbook -K -D -i inventory/hosts.yml playbook.yml -e debug=yes diff --git a/defaults/main/0_hardcoded.yml b/defaults/main/0_hardcoded.yml index ac73827..9734f9f 100644 --- a/defaults/main/0_hardcoded.yml +++ b/defaults/main/0_hardcoded.yml @@ -7,6 +7,7 @@ WAF_HC: path: cnf: '/etc/coraza-spoa' log: '/var/log/coraza-spoa' + rule_script: '/usr/local/bin/waf_coraza_rule_update.py' file: bin: '/usr/bin/coraza-spoa' bin_src: "coraza-spoa-linux-{{ arch }}" diff --git a/defaults/main/1_main.yml b/defaults/main/1_main.yml index b4d19ec..2611892 100644 --- a/defaults/main/1_main.yml +++ b/defaults/main/1_main.yml @@ -14,70 +14,89 @@ defaults_app: # see: https://github.com/corazawaf/coraza-coreruleset/releases ruleset_version: 'v4.7.0' - # see: https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40coraza.conf-recommended - # and: https://coraza.io/docs/seclang/directives/ - crs_main: - - 'SecRequestBodyAccess On' - - | - SecRule REQUEST_HEADERS:Content-Type "^(?:application(?:/soap\+|/)|text/)xml" \ - "id:'200000',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XML" - - | - SecRule REQUEST_HEADERS:Content-Type "^application/json" \ - "id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" - - 'SecRequestBodyLimit 13107200' - - 'SecRequestBodyInMemoryLimit 131072' - - 'SecRequestBodyLimitAction Reject' - - | - SecRule REQBODY_ERROR "!@eq 0" \ - "id:'200002', phase:2,t:none,log,deny,status:400,msg:'Failed to parse request body.',logdata:'%{reqbody_error_msg}',severity:2" - - | - SecRule MULTIPART_STRICT_ERROR "!@eq 0" \ - "id:'200003',phase:2,t:none,log,deny,status:400, \ - msg:'Multipart request body failed strict validation: \ - PE %{REQBODY_PROCESSOR_ERROR}, \ - BQ %{MULTIPART_BOUNDARY_QUOTED}, \ - BW %{MULTIPART_BOUNDARY_WHITESPACE}, \ - DB %{MULTIPART_DATA_BEFORE}, \ - DA %{MULTIPART_DATA_AFTER}, \ - HF %{MULTIPART_HEADER_FOLDING}, \ - LF %{MULTIPART_LF_LINE}, \ - SM %{MULTIPART_MISSING_SEMICOLON}, \ - IQ %{MULTIPART_INVALID_QUOTING}, \ - IP %{MULTIPART_INVALID_PART}, \ - IH %{MULTIPART_INVALID_HEADER_FOLDING}, \ - FL %{MULTIPART_FILE_LIMIT_EXCEEDED}'" - - | - SecRule MULTIPART_UNMATCHED_BOUNDARY "@eq 1" \ - "id:'200004',phase:2,t:none,log,deny,msg:'Multipart parser detected a possible unmatched boundary.'" - - | - SecRule TX:/^COR_/ "!@streq 0" \ - "id:'200005',phase:2,t:none,deny,msg:'Coraza internal error flagged: %{MATCHED_VAR_NAME}'" - - 'SecResponseBodyAccess On' - - 'SecResponseBodyMimeType text/plain text/html text/xml' - - 'SecResponseBodyLimit 524288' - - 'SecResponseBodyLimitAction ProcessPartial' - - 'SecDataDir /tmp/' - - 'SecAuditEngine RelevantOnly' - - 'SecAuditLogRelevantStatus "^(?:(5|4)(0|1)[0-9])$"' - - 'SecAuditLogParts ABIJDEFHZ' - - 'SecAuditLogType Serial' - - 'SecArgumentSeparator &' - - 'SecCookieFormat 0' + rules: + # see: https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40coraza.conf-recommended + # and: https://coraza.io/docs/seclang/directives/ + main: + - 'SecRequestBodyAccess On' + - | + SecRule REQUEST_HEADERS:Content-Type "^(?:application(?:/soap\+|/)|text/)xml" \ + "id:'200000',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XML" + - | + SecRule REQUEST_HEADERS:Content-Type "^application/json" \ + "id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" + - 'SecRequestBodyLimit 13107200' + - 'SecRequestBodyInMemoryLimit 131072' + - 'SecRequestBodyLimitAction Reject' + - | + SecRule REQBODY_ERROR "!@eq 0" \ + "id:'200002', phase:2,t:none,log,deny,status:400,msg:'Failed to parse request body.',logdata:'%{reqbody_error_msg}',severity:2" + - | + SecRule MULTIPART_STRICT_ERROR "!@eq 0" \ + "id:'200003',phase:2,t:none,log,deny,status:400, \ + msg:'Multipart request body failed strict validation: \ + PE %{REQBODY_PROCESSOR_ERROR}, \ + BQ %{MULTIPART_BOUNDARY_QUOTED}, \ + BW %{MULTIPART_BOUNDARY_WHITESPACE}, \ + DB %{MULTIPART_DATA_BEFORE}, \ + DA %{MULTIPART_DATA_AFTER}, \ + HF %{MULTIPART_HEADER_FOLDING}, \ + LF %{MULTIPART_LF_LINE}, \ + SM %{MULTIPART_MISSING_SEMICOLON}, \ + IQ %{MULTIPART_INVALID_QUOTING}, \ + IP %{MULTIPART_INVALID_PART}, \ + IH %{MULTIPART_INVALID_HEADER_FOLDING}, \ + FL %{MULTIPART_FILE_LIMIT_EXCEEDED}'" + - | + SecRule MULTIPART_UNMATCHED_BOUNDARY "@eq 1" \ + "id:'200004',phase:2,t:none,log,deny,msg:'Multipart parser detected a possible unmatched boundary.'" + - | + SecRule TX:/^COR_/ "!@streq 0" \ + "id:'200005',phase:2,t:none,deny,msg:'Coraza internal error flagged: %{MATCHED_VAR_NAME}'" + - 'SecResponseBodyAccess On' + - 'SecResponseBodyMimeType text/plain text/html text/xml' + - 'SecResponseBodyLimit 524288' + - 'SecResponseBodyLimitAction ProcessPartial' + - 'SecDataDir /tmp/' + - 'SecAuditEngine RelevantOnly' + - 'SecAuditLogRelevantStatus "^(?:(5|4)(0|1)[0-9])$"' + - 'SecAuditLogParts ABIJDEFHZ' + - 'SecAuditLogType Serial' + - 'SecArgumentSeparator &' + - 'SecCookieFormat 0' - # see: https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40crs-setup.conf.example - crs_setup: - - 'SecDefaultAction "phase:1,log,auditlog,pass"' - - 'SecDefaultAction "phase:2,log,auditlog,pass"' - - | - SecAction \ - "id:900990,\ - phase:1,\ - pass,\ - t:none,\ - nolog,\ - tag:'OWASP_CRS',\ - ver:'OWASP_CRS/4.7.0',\ - setvar:tx.crs_setup_version=470" + # see: https://github.com/corazawaf/coraza-coreruleset/blob/main/rules/%40crs-setup.conf.example + setup: + - 'SecDefaultAction "phase:1,log,auditlog,pass"' + - 'SecDefaultAction "phase:2,log,auditlog,pass"' + - | + SecAction \ + "id:900990,\ + phase:1,\ + pass,\ + t:none,\ + nolog,\ + tag:'OWASP_CRS',\ + ver:'OWASP_CRS/4.7.0',\ + setvar:tx.crs_setup_version=470" + + # see: REQUEST-901-INITIALIZATION.conf + vars: {} + # tx.allowed_methods: 'GET HEAD POST PUT OPTIONS' + + rule_changes: {} + # # disable whole rule-file + # 'REQUEST-933-APPLICATION-ATTACK-PHP.conf': false + # + # 'REQUEST-944-APPLICATION-ATTACK-JAVA.conf': + # # disable single rule + # 944100: false + # + # # replace single rule + # 944110: | + # SecRule ARGS|ARGS_NAMES|REQUEST_COOKIES|!REQUEST_COOKIES:/__utm/|REQUEST_COOKIES_NAMES|REQUEST_BODY|REQUEST_HEADERS|XML:/*|XML://@* "@rx (?:runtime|processbuilder)" \ + # "id:944110,\ + # ... defaults_waf: # to download pre-compiled binary from 'github.com/O-X-L/coraza-spoa' diff --git a/files/usr/local/bin/waf_coraza_rule_update.py b/files/usr/local/bin/waf_coraza_rule_update.py new file mode 100644 index 0000000..1368825 --- /dev/null +++ b/files/usr/local/bin/waf_coraza_rule_update.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +# {{ ansible_managed }} +# ansibleguy.haproxy_waf_coraza + +# Copyright: Rath Pascal Rene +# License: MIT + +from pathlib import Path +from sys import exit as sys_exit +from argparse import ArgumentParser + +STATE_DISABLE = 'disable' +STATE_REPLACE = 'replace' +NEWLINE_CHAR = '\\n' +RULE_START = 'SecRule ' +RULE_ID_START = '"id:' + + +def _error(msg: str): + print(f'ERROR: {msg}') + sys_exit(1) + + +def main(): + if not Path(args.rule_file).is_file(): + _error('File not found') + + with open(args.rule_file, 'r', encoding='utf-8') as f: + in_rules = f.readlines() + + line_from = -1 + for line_nr, line in enumerate(in_rules): + if line.find(f'{RULE_ID_START}{args.rule_id}') != -1: + if line.startswith(RULE_START): + line_from = line_nr + + else: + line_from = line_nr - 1 + + break + + if line_from == -1: + _error('Rule not found') + + out_rules = [] + done = False + changed = False + + for line_nr, line in enumerate(in_rules): + if not done and line_nr >= line_from: + if not line_nr == line_from and line.startswith(RULE_START): + # insert new + if args.state == STATE_REPLACE: + out_rules.extend( + [f'{line}\n' for line in args.rule_replacement.split(NEWLINE_CHAR)] + ) + + out_rules.append(line) + + done = True + if changed: + print(f'Rule {args.rule_id} {args.state}d') + + continue + + # comment-out + if args.state == STATE_DISABLE: + if not line.startswith('#'): + line = f'#{line}' + changed = True + + # remove old + elif args.state == STATE_REPLACE: + continue + + # comment-in + else: + if line.startswith('#'): + line = f'{line[1:]}' + changed = True + + out_rules.append(line) + + if not args.check_mode: + with open(args.rule_file, 'w', encoding='utf-8') as f: + f.writelines(out_rules) + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument( + '-c', '--check-mode', type=bool, + default=False, help='Dry-run check-mode - changes are not written', + ) + parser.add_argument( + '-f', '--rule-file', type=str, + required=True, help='Full path to the rule config-file', + ) + parser.add_argument( + '-i', '--rule-id', type=str, + required=True, help='ID of the Rule to modify', + ) + parser.add_argument( + '-s', '--state', type=str, choices=['enable', STATE_DISABLE, STATE_REPLACE], + required=True, help='ID of the Rule to modify' + ) + parser.add_argument( + '-r', '--rule-replacement', type=str, help='ID of the Rule to modify', + ) + + args = parser.parse_args() + + if args.state == STATE_REPLACE: + if args.rule_replacement is None: + _error('No Rule-replacement provided') + + if args.rule_replacement.find(f'{RULE_ID_START}{args.rule_id}') == -1: + _error('Missing rule-id in Rule-replacement') + + if not args.rule_replacement.startswith(RULE_START): + _error('Missing SecRule-prefix in Rule-replacement') + + if not args.rule_replacement.endswith('"'): + _error('Missing end-quote in Rule-replacement') + + main() diff --git a/filter_plugins/util.py b/filter_plugins/util.py index 73b5dc3..4240db9 100644 --- a/filter_plugins/util.py +++ b/filter_plugins/util.py @@ -7,6 +7,7 @@ def filters(self): return { 'safe_key': self.safe_key, 'unique_apps': self.unique_apps, + 'is_boolean': self.is_boolean, } @staticmethod @@ -26,3 +27,7 @@ def unique_apps(all_apps: list) -> list: pass return apps + + @staticmethod + def is_boolean(value: any) -> bool: + return isinstance(value, bool) diff --git a/tasks/debian/app.yml b/tasks/debian/app.yml index 2e05978..6620acf 100644 --- a/tasks/debian/app.yml +++ b/tasks/debian/app.yml @@ -26,16 +26,35 @@ - name: "HAProxy WAF | Apps | {{ waf_app_name }} | Add rules {{ waf_app.ruleset_version }}" ansible.builtin.shell: | - cp -r {{ waf_app_rules_default_dir }}/rules/@owasp_crs {{ crs_dir }} && - chown -R root:{{ WAF_HC.user }} {{ crs_dir }} + cp -r {{ waf_app_rules_default_dir }}/rules/@owasp_crs {{ waf_app_crs_dir }} && + chown -R root:{{ WAF_HC.user }} {{ waf_app_crs_dir }} args: - creates: "{{ crs_dir }}" - vars: - crs_dir: "{{ waf_app_rules_dir }}/@owasp_crs" + creates: "{{ waf_app_crs_dir }}" notify: ['WAF-restart', 'Check-failed'] tags: skip_ansible_lint # command-instead-of-module -# todo: rule-overrides +- name: "HAProxy WAF | Apps | {{ waf_app_name }} | Update Variables" + ansible.builtin.lineinfile: + path: "{{ waf_app_rules_dir }}/@owasp_crs/REQUEST-901-INITIALIZATION.conf" + regexp: "^(.*)setvar:'{{ item.key }}=(.*)'(.*)$" + line: "\\1setvar:'{{ item.key }}={{ item.value }}'\\3" + backrefs: true + with_dict: "{{ waf_app.rules.vars }}" + tags: [rules] + +- name: "HAProxy WAF | Apps | {{ waf_app_name }} | Rule Changes" + ansible.builtin.include_tasks: debian/app_rule_changes.yml + vars: + rule_file: "{{ waf_app_crs_dir }}/{{ rule_change_item.key }}" + rule_file_cnf: "{{ rule_change_item.value }}" + loop_control: + loop_var: rule_change_item + with_dict: "{{ waf_app.rules.rule_changes }}" + no_log: true # less output + tags: [rules, apps] + args: + apply: + tags: [rules, apps] - name: "HAProxy WAF | Apps | {{ waf_app_name }} | Create main config" ansible.builtin.template: diff --git a/tasks/debian/app_rule_changes.yml b/tasks/debian/app_rule_changes.yml new file mode 100644 index 0000000..265b71e --- /dev/null +++ b/tasks/debian/app_rule_changes.yml @@ -0,0 +1,48 @@ +--- + +- name: "HAProxy WAF | Apps | {{ waf_app_name }} | Disable Rule-File" + ansible.builtin.shell: | + if [ -f '{{ rule_file }}' ] + then + mv '{{ rule_file }}' '{{ rule_file }}.disabled' + echo '1' + else + echo '0' + fi + args: + executable: '/bin/bash' + register: waf_app_rule_file_dis + changed_when: waf_app_rule_file_dis.stdout != '0' + when: + - rule_file_cnf | is_boolean + - not rule_file_cnf | bool + notify: ['WAF-restart', 'Check-failed'] + +- name: "HAProxy WAF | Apps | {{ waf_app_name }} | Enable Rule-File" + ansible.builtin.shell: | + if [ -f '{{ rule_file }}.disabled' ] + then + mv '{{ rule_file }}.disabled' '{{ rule_file }}' + echo '1' + else + echo '0' + fi + args: + executable: '/bin/bash' + register: waf_app_rule_file_en + changed_when: waf_app_rule_file_en.stdout != '0' + when: + - rule_file_cnf | is_boolean + - rule_file_cnf | bool + notify: ['WAF-restart', 'Check-failed'] + +- name: "HAProxy WAF | Apps | {{ waf_app_name }} | Rule State-Changes" + ansible.builtin.include_tasks: debian/app_rule_changes_state.yml + vars: + ansible_check_mode2: "{{ ansible_check_mode | bool }}" + no_log: true # less output + tags: [rules, apps] + args: + apply: + tags: [rules, apps] + when: not rule_file_cnf | is_boolean diff --git a/tasks/debian/app_rule_changes_state.yml b/tasks/debian/app_rule_changes_state.yml new file mode 100644 index 0000000..885ead6 --- /dev/null +++ b/tasks/debian/app_rule_changes_state.yml @@ -0,0 +1,54 @@ +--- + +- name: "HAProxy WAF | Apps | {{ waf_app_name }} | Disable Rules" + ansible.builtin.command: | + python3 {{ WAF_HC.path.rule_script }} -s disable -f {{ rule_file }} -i {{ rule_id }} + {% if ansible_check_mode %}-c true{% endif %} + register: waf_app_rule_dis + changed_when: "'disabled' in waf_app_rule_dis.stdout" + when: + - not rule_file_cnf | is_boolean + - rule_file_cnf | length > 0 + - rule_cnf | is_boolean + - not rule_cnf | bool + vars: + rule_id: "{{ item.key }}" + rule_cnf: "{{ item.value }}" + with_dict: "{{ rule_file_cnf }}" + check_mode: false + notify: ['WAF-restart', 'Check-failed'] + +- name: "HAProxy WAF | Apps | {{ waf_app_name }} | Enable Rules" + ansible.builtin.command: | + python3 {{ WAF_HC.path.rule_script }} -s enable -f {{ rule_file }} -i {{ rule_id }} + {% if ansible_check_mode %}-c true{% endif %} + register: waf_app_rule_en + changed_when: "'enabled' in waf_app_rule_en.stdout" + when: + - not rule_file_cnf | is_boolean + - rule_file_cnf | length > 0 + - rule_cnf | is_boolean + - rule_cnf | bool + vars: + rule_id: "{{ item.key }}" + rule_cnf: "{{ item.value }}" + with_dict: "{{ rule_file_cnf }}" + check_mode: false + notify: ['WAF-restart', 'Check-failed'] + +- name: "HAProxy WAF | Apps | {{ waf_app_name }} | Replace Rules" + ansible.builtin.command: | + python3 {{ WAF_HC.path.rule_script }} -s replace -f {{ rule_file }} -i {{ rule_id }} -r {{ rule_cnf }} + {% if ansible_check_mode %}-c true{% endif %} + register: waf_app_rule_repl + changed_when: "'replaced' in waf_app_rule_repl.stdout" + when: + - not rule_file_cnf | is_boolean + - rule_file_cnf | length > 0 + - not rule_cnf | is_boolean + vars: + rule_id: "{{ item.key }}" + rule_cnf: "{{ item.value }}" + with_dict: "{{ rule_file_cnf }}" + check_mode: false + notify: ['WAF-restart', 'Check-failed'] diff --git a/tasks/debian/main.yml b/tasks/debian/main.yml index 22dd208..6428ba0 100644 --- a/tasks/debian/main.yml +++ b/tasks/debian/main.yml @@ -37,17 +37,26 @@ notify: ['WAF-restart', 'Check-failed'] tags: [config, apps] +- name: HAProxy WAF | Rule Update-Script + ansible.builtin.copy: + src: "files/{{ WAF_HC.path.rule_script }}" + dest: "{{ WAF_HC.path.rule_script }}" + mode: 0750 + tags: [rules] + check_mode: false + - name: HAProxy WAF | Apps ansible.builtin.include_tasks: debian/app.yml vars: waf_app: "{{ defaults_app | combine(waf_app_user, recursive=true) }}" waf_app_name: "{{ waf_app.name | safe_key }}" waf_app_rules_dir: "{{ WAF_HC.path.cnf }}/{{ WAF_HC.path.dir.cnf_rules }}/{{ waf_app_name }}/{{ waf_app.ruleset_version }}" + waf_app_crs_dir: "{{ waf_app_rules_dir }}/@owasp_crs" waf_app_rules_default_dir: "{{ WAF_HC.path.cnf }}/{{ WAF_HC.path.dir.cnf_rules }}/_tmpl/{{ waf_app.ruleset_version }}" loop_control: loop_var: waf_app_user loop: "{{ WAF_CONFIG.apps | unique_apps }}" - no_log: true + no_log: true # less output tags: [config, rules, apps] args: apply: diff --git a/tasks/main.yml b/tasks/main.yml index a0588ef..54e040c 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -4,6 +4,8 @@ ansible.builtin.assert: that: - waf is defined + - waf.apps is defined + - waf.apps | length > 0 tags: always - name: HAProxy WAF | Processing debian config diff --git a/templates/etc/coraza/apps/tmpl/@crs-setup.conf.j2 b/templates/etc/coraza/apps/tmpl/@crs-setup.conf.j2 index 3104cd0..21dcdaa 100644 --- a/templates/etc/coraza/apps/tmpl/@crs-setup.conf.j2 +++ b/templates/etc/coraza/apps/tmpl/@crs-setup.conf.j2 @@ -1,7 +1,7 @@ # {{ ansible_managed }} # ansibleguy.haproxy_waf_coraza -{% for s in waf_app.crs_setup %} +{% for s in waf_app.rules.setup %} {{ s }} {% endfor %} diff --git a/templates/etc/coraza/apps/tmpl/main.conf.j2 b/templates/etc/coraza/apps/tmpl/main.conf.j2 index 5b30fde..8ab8619 100644 --- a/templates/etc/coraza/apps/tmpl/main.conf.j2 +++ b/templates/etc/coraza/apps/tmpl/main.conf.j2 @@ -1,11 +1,11 @@ # {{ ansible_managed }} # ansibleguy.haproxy_waf_coraza -{% if 'SecRuleEngine' not in waf_app.crs_main | join('') %} +{% if 'SecRuleEngine' not in waf_app.rules.main | join('') %} SecRuleEngine {{ 'On' if waf_app.block | bool else 'DetectionOnly' }} {% endif %} -{% for s in waf_app.crs_main %} +{% for s in waf_app.rules.main %} {{ s }} {% endfor %}