GUI editor to tame mod_security rules

βŒˆβŒ‹ βŽ‡ branch:  modseccfg


Check-in [7c310d47ec]

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:Build data bag() from log line. Complete some comments for recipies, and fix Macro usage with NEWID generator. New recipes for Cloudflare and Log formats (incomplete).
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 7c310d47ec55caf8bf46baf5c4d127d23eab908723ee1e8489b07eb4542331e9
User & Date: mario 2020-11-25 18:45:18
Original Comment: Build data bad() from log line. Complete some comments for recipies, and fix Macro usage with NEWID generator. New recipes for Cloudflare and Log formats (incomplete).
Context
2020-11-26
00:26
Release as 0.4.0 check-in: 393a1fb162 user: mario tags: trunk, 0.4.0
2020-11-25
18:45
Build data bag() from log line. Complete some comments for recipies, and fix Macro usage with NEWID generator. New recipes for Cloudflare and Log formats (incomplete). check-in: 7c310d47ec user: mario tags: trunk
18:43
Introduce vhost.warn message (for writeability or multiple vhost warnings in mainwindow.status bar) check-in: 2ff5b44713 user: mario tags: trunk
Changes
Hide Diffs Unified Diffs Ignore Whitespace Patch

Changes to modseccfg/recipe.py.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46


47
48
49
50


51
52
53
54


55
56
57
58


59
60
61
62


63
64
65







66






67






68

69
70

71








































72
73
74



75
76
77
78
79
80
81


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105






















106
# api: modseccfg
# encoding: utf-8
# version: 0.0
# type: data
# title: recipe
# description: Apache/mod_security config examples or conversions
# category: config
# config:
#    { name: replace_rules, type: bool, value: 0, description: "try to find replacement spot, else just append" }
#
# Basically just blobs of text and an editor window.
# [Save] will append directives to selected vhost/*.conf file.
#
# Some samples from:
#  Β· https://wiki.atomicorp.com/wiki/index.php/Mod_security
#


from modseccfg import utils, vhosts
import PySimpleGUI as sg
import re
from textwrap import dedent



class recipe:

    location = """
      <Location "/app/">
        SecRuleRemoveById $id   #@wrap

      </Location>
    """

    directory = """
      <Directory "/srv/www/app/">
        SecRuleRemoveById $id   #@wrap
      </Directory>
    """

    filesmatch = """
      <FilesMatch "*.php">
        SecRuleRemoveById $id   #@wrap
      </FilesMatch>
    """
    
    exclude_parameter = """


       SecRuleUpdateTargetByID $id "!ARGS:param"
    """
    
    rule_to_detectiononly = """


       SecRuleUpdateActionById $id "pass,status:200,log,auditlog"
    """

    url_to_detectiononly = """


       SecRule REQUEST_URI "$request_uri" "phase:1,id:$new_id,t:none,t:lowercase,pass,msg:'DetectionOnly for $request_uri',ctl:ruleEngine=DetectionOnly"
    """
    
    exempt_remote_addr = """


       SecRule REMOTE_ADDR "^\\Q$remote_addr\\E$" "phase:1,id:$new_id,t:none,nolog,allow,ctl:ruleEngine=Off,ctl:auditEngine=Off"
    """

    whitelist_file = """


       SecRule REMOTE_ADDR "@pmFromFile $confn.whitelist" "phase:1,id:$new_id,t:none,nolog,allow"
    """
    







    macros = """






    <IfModule mod_alias.c>






      <Macro SecRuleRemoveByPath $id $path>

        SecRule REQUEST_URI "@eq $request_uri" "id:$new_id,t:none,msg:'Whitelist $request_uri',ctl:removeById=$id"
      </Macro>

    </IfModule>








































    """

    @staticmethod



    def has(name):
        return hasattr(recipe, name)

    @staticmethod
    def show(name, data, id=0, vhost={}):
    
        # resolve


        text = getattr(recipe, name)
        if type(text) is str:
            text = dedent(text)
            if re.search(r"\$(id|path|tag)", text):
                if data.get("rule"):
                    text =  re.sub(r"\$id", str(data["rule"][0]), text)
                #@ToDo: mainwindow should supply a data bag (either full secrule entry, or params from log - depending on which is active)
        else:
            text = text(data)
        print(data)
        print(text)
    
        # window
        w = sg.Window(title=f"Recipe '{name}'", layout=[
            [sg.Multiline(default_text=text, key="src", size=(80,20), font="Sans 14")],
            [sg.Button("Save", key="save"), sg.Button("Cancel", key="cancel")]
        ])
        event, values = w.read()
        print(event, values)
        w.close()
        
        # write …
        if event == "save":
            writer.append(vhost.fn, text)

























|

















|




|

|
|
|
>
|



|
|
|



|
|
|



>
>
|



>
>
|



>
>
|



>
>
|


|
>
>
|


>
>
>
>
>
>
>

>
>
>
>
>
>
|
>
>
>
>
>
>
|
>
|
|
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>


|
>
>
>
|
|

<
|
<
|
>
>
|
|
|
<
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152

153

154
155
156
157
158
159


160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# api: modseccfg
# encoding: utf-8
# version: 0.2
# type: data
# title: recipe
# description: Apache/mod_security config examples or conversions
# category: config
# config:
#    { name: replace_rules, type: bool, value: 0, description: "try to find replacement spot, else just append" }
#
# Basically just blobs of text and an editor window.
# [Save] will append directives to selected vhost/*.conf file.
#
# Some samples from:
#  Β· https://wiki.atomicorp.com/wiki/index.php/Mod_security
#


from modseccfg import utils, vhosts
import PySimpleGUI as sg
import re, random
from textwrap import dedent



class templates:

    locationmatch = """
        <LocationMatch "/app/">
          SecRuleRemoveById $id   # $msg
          # SecRuleEngine DetectionOnly
        </LocationMatch>
    """

    directory = """
        <Directory "/srv/www/app/">
          SecRuleRemoveById $id   # $msg
        </Directory>
    """

    filesmatch = """
        <FilesMatch "\.php$">
          SecRuleRemoveById $id   # $msg
        </FilesMatch>
    """
    
    exclude_parameter = """
        # Exclude GET/POST parameter from rule
        #
        SecRuleUpdateTargetByID $id "!ARGS:param"
    """
    
    rule_to_detectiononly = """
        # One rule to DetectionOnly
        #
        SecRuleUpdateActionById $id "pass,status:200,log,auditlog"
    """

    url_to_detectiononly = """
        # Set one URL to DetectionOnly
        #
        SecRule REQUEST_URI "$request_uri" "phase:1,id:$rand,t:none,t:lowercase,pass,msg:'DetectionOnly for $request_uri',ctl:ruleEngine=DetectionOnly"
    """
    
    exempt_remote_addr = """
        # Exempt client addr from all SecRules
        #
        SecRule REMOTE_ADDR "^\\Q$remote_addr\\E$" "phase:1,id:$rand,t:none,nolog,allow,ctl:ruleEngine=Off,ctl:auditEngine=Off"
    """

    whitelist_ip_file = """
        # List of IPs from filename trigger DetectionOnly mode
        #
        SecRule REMOTE_ADDR "@pmFromFile $confn.whitelist" "phase:1,id:$rand,t:none,nolog,allow,ctl:ruleEngine=DetectionOnly"
    """
    
    ip2location = """
        # Use mod_ip2location or Cloudflare header
        #
        SetEnvIfExpr "req('CF-IPCountry') =~ '\w\w'" IP2LOCATION_COUNTRY_SHORT=%{HTTP_CF_IPCOUNTRY}
        SecRule ENV:IP2LOCATION_COUNTRY_SHORT "!^(UK|DE|FR)$" "id:$rand,deny,status:500,msg:'Req not from whitelisted country'"
    """
    
    macros = """
        # This directive block defines some macros, which you can use to simplify a few
        # SecRules exceptions. Best applied to a central *.conf file, rather# than vhost.
        # An `Use` directive/prefix is necessary to expand these macros.
        #     ↓
        #    Use SecRuleRemoveByPath 900410 /app/exempt/
        #
        <IfModule mod_macro.c>

          <Macro NEWID $STR>
            # define %{ENV:NEWID} in the 50000 range; might yield duplicates
            SetEnvIfExpr "md5('$STR') =~ /(\d).*(\d).*(\d).*(\d)/" "NEWID=5$1$2$3$4"
          </Macro>

          <Macro SecRuleRemoveByPath $ID $PATH>
            Use NEWID "$ID$PATH"
            SecRule REQUEST_URI "@eq $PATH" "id:%{ENV:NEWID},t:none,msg:'Whitelist Β«$PATHΒ»',ctl:removeById=$ID"
          </Macro>

        </IfModule>
    """
    
    apache_cloudflare_remoteip = """
        # Sets REMOTE_ADDR for Apache at large.
        # @url https://support.cloudflare.com/hc/en-us/articles/360029696071-Orig-IPs
        # @bug Seemingly mod_security needs another fix.
        #
        <IfModule mod_remoteip.c>
           RemoteIPHeader CF-Connecting-IP
           RemoteIPTrustedProxy 173.245.48.0/20
           RemoteIPTrustedProxy 103.21.244.0/22
           RemoteIPTrustedProxy 103.22.200.0/22
           RemoteIPTrustedProxy 103.31.4.0/22
           RemoteIPTrustedProxy 141.101.64.0/18
           RemoteIPTrustedProxy 108.162.192.0/18
           RemoteIPTrustedProxy 190.93.240.0/20
           RemoteIPTrustedProxy 188.114.96.0/20
           RemoteIPTrustedProxy 197.234.240.0/22
           RemoteIPTrustedProxy 198.41.128.0/17
           RemoteIPTrustedProxy 162.158.0.0/15
           RemoteIPTrustedProxy 104.16.0.0/12
           RemoteIPTrustedProxy 172.64.0.0/13
           RemoteIPTrustedProxy 131.0.72.0/22
           RemoteIPTrustedProxy 2400:cb00::/32
           RemoteIPTrustedProxy 2606:4700::/32
           RemoteIPTrustedProxy 2803:f800::/32
           RemoteIPTrustedProxy 2405:b500::/32
           RemoteIPTrustedProxy 2405:8100::/32
           RemoteIPTrustedProxy 2a06:98c0::/29
           RemoteIPTrustedProxy 2c0f:f248::/32
        </IfModule>
    """
     
    apache_errorlog_format = """
        # Extend error log w/ REQUEST_URI and somewhat standard datetime format (not quite 8601)
        #
        #  β†’ Feedback appreciated. What ought to be the post-90s Apache default?
        #
        SetEnvIf Request_URI "(^.*$)" REQ=$1
        ErrorLogFormat "[%{cu}t] [%m:%l] [pid %P:tid %T] [client %a] %E: %M [request_uri %{REQ}e]"
    """


def ls():
    return [title.replace("_", " ").title() for title in templates.__dict__.keys() if not title.startswith("__")]

def has(name):
    return hasattr(templates, name.lower().replace(" ", "_"))


def show(name, data, id=0, vhost={}):

    # resolve
    vars = bag(data)
    name = name.lower().replace(" ", "_")
    text = getattr(templates, name)
    if type(text) is str:
        text = dedent(text).lstrip()


        text = repl(text, vars)
            #@ToDo: mainwindow should supply a data bag (either full secrule entry, or params from log - depending on which is active)
    else:
        text = text(data, vars)
    print(data)
    print(text)

    # window
    w = sg.Window(title=f"Recipe '{name}'", resizable=1, layout=[
        [sg.Multiline(default_text=text, key="src", size=(90,24), font="Mono 12")],
        [sg.Button("Save", key="save"), sg.Button("Cancel", key="cancel")]
    ])
    event, values = w.read()
    print(event, values)
    w.close()
    
    # write …
    if event == "save":
        writer.append(vhost.fn, text)


# prepare vars dict from mainwindow event data + selected log line
def bag(data):
    vars = {
        "id": "0",
        "rand": random.randrange(2000,5000),
        "request_uri": "/PATH",
        "confn": data.get("confn")
    }
    if data.get("log"):
        for k,v in re.findall('\[(\w+) "([^"]+)"\]', str(data["log"])):
            if k in ("uri", "request_line"): k = "request_uri"
            vars[k] = v
    if data.get("rule"):
        vars["id"] = data["rule"][0]
    return vars

# substitute $varnames in text string
def repl(text, vars):
    text = re.sub(r"\$(\w+)", lambda m,*k: str(vars.get(m.group(1), m.group(0))), text)
    return text