GUI editor to tame mod_security rules

⌈⌋ branch:  modseccfg


Check-in [5f35cb034d]

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

Overview
Comment:Initial prototype (conf parser, log reader, mainwindow somewhat functional)
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk | 0.0.9
Files: files | file ages | folders
SHA3-256: 5f35cb034d0f79cc29b25b510c4924a88c2ec23075dc3a80dfd775f45f2b9922
User & Date: mario 2020-11-13 14:50:18
Context
2020-11-13
15:20
Update README with GIF, use .rst for pkg README. check-in: fd5f570868 user: mario tags: trunk
14:50
Initial prototype (conf parser, log reader, mainwindow somewhat functional) check-in: 5f35cb034d user: mario tags: trunk, 0.0.9
14:46
initial empty check-in check-in: 4989e432a6 user: mario tags: trunk
Changes
Hide Diffs Unified Diffs Ignore Whitespace Patch

Added Makefile.





















>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
run:
	./g.py

setup:
	version --read modseccfg/__init__.py --incr --write
	python3 setup.py bdist_wheel
	rm -r modseccfg.egg-info

upload:
	python3 setup.py upload

Added README.md.

























































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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
> *WARNING: THIS IS ALPHA STAGE QUALITY AND WILL MOST CERTAINLY DELETE YOUR APACHE CONFIGURATION*
> (It doesn't, but: no waranty and such.)

## modseccfg

 * Simple GUI editor for SecRuleDisableById settings
 * Tries to suggest false positives from error and audit logs
 * And a few options to configure mod_security and CRS variables.
 * Obviously requires `ssh -X` forwarding, or preparing config
   rules on a local test setup, and `*.conf` files to be writable
   by current user (running as root is not advised).

# Usage

You obviously should have Apache(2.x) + mod_security(2.9) + CRS(3.x) set up
and running already (in DetectionOnly mode initially), to allow for log
inspection and adapting rules.

 1. start modseccfg (`python3 -m modseccfg`)
 2. Select a configuration/vhost file to inspect + work on.
 3. Pick the according error.log
 4. Inspect the rules with a high error count.
 5. [Disable] offending rules (if they're not essential to CRS, or would
    likely poke holes into useful protections).
 6. Thenceforth restart Apache after testing changes (`apache2ctl -t`).

## Notes

 * Preferrably do not edit default `/etc/apache*` files
 * Work on separated `/srv/web/conf.d/*` configuration, if available
 * And keep vhost settings in e.g. `vhost.*.dir` files, rather than
   multiple `<VirtualHost>` in one `*.conf` (else only the first section
   will be augmented).

## Missing features

 * Doesn't process any audit.log yet.
 * Can't classify wrapped (`<Location>` or other directives) rules yet.
 * No rule information dialog.
 * No SecOption editor yet.
 * No CRS settings (setvar:crs…) editor yet.
 * Recipes are not worth using yet.
 * No sudo usage.
 * No support for nginx or mod_sec v3.

Added g.py.







>
>
>
1
2
3
#!/usr/bin/env python3
import modseccfg.mainwindow
modseccfg.mainwindow.main()

Added modseccfg/__init__.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
# encoding: utf-8
# api: python
# type: init
# title: modseccfg
# description: Editor to tame mod_security rulesets
# version: 0.0.9
# state:   prototype
# support: none
# license: ASL
# depends: python:pysimplegui (>= 3.0), python:pluginconf (>= 0.7.2)
# priority: core
# url: https://fossil.include-once.org/modseccfg/
# category: config
# classifiers: x11, http
#
# Correlates mod_security SecRules to logs, and simplifies
# disabling unneeded rules. It's very basic and not gonna
# win any usability awards.
# BE WARNED THAT ALPHA RELEASES MAY DAMAGE YOUR APACHE SETUP.
#
# Basically you select your desired vhost *.conf file, then
# hit [Disable] for rules with a high error.log count - if it's
# false positives. Preferrably leave rules untouched that are
# indeed working as intended.
#


    

Added modseccfg/__main__.py.





























>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# api: python
# type: virtual
# priority: hidden
# title: module invocation
# description: python -m modseccfg
# version: 0.0
# category: cli
#
# Just a wrapper to allow starting module directly.
#

if __name__ == "__main__":
    from modseccfg.mainwindow import main
    main()

Added modseccfg/appsettings.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
# api: python
# type: io
# title: app settings
# description: read/store modseccfg options
# depends: pluginconf (>= 0.7.2), python:appdirs (>= 1.3), python:pathlib
# category: config
# version: 0.1
# config:
#    { name: "test[key]", type: bool, value: 1, description: "Array[key] test" }
# 
# Basically just a dictionary for the GUI and some
# module behaviours. GUI courtesy of pluginconf.
#


import json, os
from modseccfg.utils import expandpath
import pluginconf, pluginconf.gui
#import appdirs

# defaults
conf = {
    "theme": "DarkGrey",
    "edit_sys_files": False,
    "backup_files": True,
    "log_entries": 5000,
    "log_filter": "(?!404|429)[45]\d\d",
    "log_skip_rx" : "PetalBot|/.well-known/ignore.cgi",
    "max_rule_range": 1000,  # obsolete already (SecRuleDisById ranges do a lookup)
    "backup_dir": expandpath("~/backup-config/"),
    "conf_file": expandpath("~/.config/modseccfg.json")
}

# plugin lookup
pluginconf.module_base=__name__
pluginconf.plugin_base=["modseccfg"]
for module,meta in pluginconf.all_plugin_meta().items():
    pluginconf.add_plugin_defaults(conf, {}, meta, module)
#print(__package__)
#print(pluginconf.module_list())
#print(conf)

# read config file
def read():
    if os.path.exists(conf["conf_file"]):
        conf.update(json.load(open(conf["conf_file"], "r")))

# write config file
def write():
    if not os.path.exists(os.path.dirname(conf["conf_file"])):
        os.mkdir(os.path.dirname(conf["conf_file"]))
    print(str(conf))
    json.dump(conf, open(conf["conf_file"], "w"), indent=4)

# show config option dialog
def window(mainself, *kargs):
    pluginstates = {"mainwindow": 1, "__init__":1,"appsettings":1,"vhosts":1,"logs":1,"writer":1}
    fn_py = __file__.replace("appsettings", "*")
    save = pluginconf.gui.window(conf, pluginstates, files=[fn_py], theme=conf["theme"])
    if save:
        write()

# initialze conf{}
read()

Added modseccfg/icons.py.

























>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
# api: pysimplegui
# type: data
# category: image
# title: icons
# description: embedded PNG files for the GUI
# version: 0.0
#
# Icons for the UI.
#

logo = b"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"
vice = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAC7AAAAuwB7TO0+gAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAALdSURBVDiNdZNNaFxVGIafc+bMnUkmM5lMm050EpVEbVJEqalICiqajT8LV13EVepCRdyoiAgG6l5UqlKIG3UnRcG6E9RgCRZtaKINdJo01doQEgyTtM69d+6ccz4XGcIk4Lv5zvfD+/3wHsUOBoAckAY22IsOoNR6/w7Y9qRu2SwQAHnAtGL3tdVlgUyrwf8iAFSb306g24j3QAVB8GB/wQTFLmPq/waL1YmJZGhhcdRis6bRELW9KU1pKothMwlc7Jwnlw05dvwmE2e21ZFy9/j81Hjtk5lrB7/+Y/XybFTsMa9/3MuRh9MUSqEWfwtB9ndOXhw7JG9ULpjrm/X42U9nS08/0LtTZAnUPYdDKZYycu6Lkl9eyAGQ6/Z6cmqDAPCB4H3qvbVzsYmsvdSI5LGUUk5rBFyo83kRZ3Qy820XWyszLC4mVAbHg1emHE0UJNBMOk990D1oAGuNJVBaayf9D1UKYf7V0cho7a331Z68Nd9BgvOWSz9rtFLitAreObOSfHbqfgNYLaKfGumNOoJMXqXINtN3ayeiltZqhcs3agEiVbq6qslbL+R3j9C45WmoKwbwCiXDlQPxcN+BCMFhrAb48nxT/VZd0yglQBPq0f5jGjn9TObx03Myu7xuPvp+6aA2uvnVyUcSAGNgZSMqAOWh3tzRd58bXgVwKSUk8NrZ+R5DZsCnzZwsXL+dvrIeXb1Wu30HWnmAo3cVt08cq+Asdw715cPJ4/dm8VYQ3PPTvwxorVaNenk6++TwIaVS2u+qTkwIMFIu8uGJ0dasvo7DorTHk1xYrv0Ths05M1DKjZ0cG9wKvcuEiALR+/fc0azK4pz8tR3L29/MFw+Xuy+uv/moMfWGVWd//TsX+UZHHMcCyifWq8DoveoTid//8ar5YWm9q6/U8dP5izdjnhg2SgTF55MZkkDz0nTU2Un/SLlQiRuy+/MyqZSOnZW1mr2xFcd/tvP+B4DSPiSzfp3PAAAAAElFTkSuQmCC"

Added modseccfg/logs.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
107
108
109
110
111
112
# api: modseccfg
# encoding: utf-8
# type: file
# category: log
# title: log reader
# description: scan error.logs / audit log
# config:
#    { name: log_entries, type: int, value: 5000, description: "How many log entries to show (whole log will be counted regardless)" }
#    { name: log_filter, type: str, value: "(?!404|429)4\d\d|5\d\d", description: "Error codes to look out for in access.logs" }
#    { name: log_skip_rx, type: str, value: "PetalBot|/.well-known/ignore.cgi", description: "Regex to skip whole log lines on" }
#    { name: log_search_id, type: bool, value: 0, description: "Look up rule id, if only file+line given in log (slow)" }
# version: 0.1
#
# Basic filtering and searching within the logs.
# Filters out by error codes (http 4xx/5xx) or mod_security messages.
#
# Audit log types (serial/concurrent/json) aren't supported yet.
#


import os, re
from modseccfg import utils, appsettings, vhosts


# detected rule ids and number of occurences
log_count = {}     # id→count
class state:
    log_curr = ""  # fn


# extraction rules
class rx:
    interesting = re.compile("""
        ModSecurity: |
        \[id\s"\d+"\] |
        "\s((?!429)[45]\d\d)\s\d+        # should come from conf[log_filter]
    """, re.X)
    id = re.compile("""
        (?:\[id\s|\{"id":\s*)"(\d+)"[\]\}]   # [id "…"] or json {"id":"…"}
    """, re.X)
    file_line = re.compile("""
        \[file \s "(?P<file>.+?)"\] \s* \[line \s "(?P<line>\d+)"\]
    """, re.X)
    shorten = re.compile("""
        :\d\d.\d+(?=\]) |
        \s\[pid\s\d[^\]]*\] |
        \s\[tag\s"[\w\-\.\/]+"\] |
        \s\[client\s[\d\.:]+\] |
        \sRule\s[0-9a-f]{12} |
        (?<=\[file\s")/usr/share/modsecurity-crs/rules/ |
    """, re.X)


# search through log file, filter, extract rule ids, return list of log lines
def scan_log(fn):

    if fn == state.log_curr:
        return   # no update
    state.log_curr = ""
    if not os.path.exists(fn):
        return
    log_curr = fn
    
    # filter lines
    log_lines = []
    for line in open(fn, "r"):
        if rx.interesting.search(line):
            if re.search(appsettings.conf["log_skip_rx"], line):
                continue
            m = rx.id.search(line)
            if m:
                incr_log_count(int(m.group(1)))
            elif appsettings.conf["log_search_id"]:
                m = rx.file_line.search(line)
                if m:
                    id = search_id(m.group("file"), m.group("line"))
                    if id:
                        incr_log_count(int(id))
            log_lines.append(line.strip())

    # slice entries
    if len(log_lines) >= appsettings.conf["log_entries"]:
        log_lines = log_lines[-appsettings.conf["log_entries"]:]
    # shorten infos in line
    log_lines = [rx.shorten.sub("", line) for line in log_lines]
    return log_lines

# count++
def incr_log_count(id):
    if id in log_count:
        log_count[id] += 1
    else:
        log_count[id] = 1

# search [id …] from only [file …] and [line …] - using vhosts.linemap{}
def search_id(file, line):
    print("linemap:", file, line)
    if file and line:
        vh = vhosts.vhosts.get(file)
        if vh:
            return vh.line_to_id(int(line))
    return 0


# assemble list of error/access/audit logs
def find_logs():
    log_list = []
    for fn,vh in vhosts.vhosts.items():
        log_list = log_list + vh.logs
    log_list.append("./fossil.error.log")  # testing
    return list(set(log_list))

Added modseccfg/mainwindow.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
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
# encoding: utf-8
# api: python
# type: main
# title: main window
# description: GUI with menus, actions, rules and logs
# category: config
# version: 0.1.0
# state:   alpha
# license: ASL
# config: 
#    { name: theme, type: select, value: DarkRed1, select: "Default|DarkGrey|Black|BlueMono|BluePurple|BrightColors|BrownBlue|Dark|Dark2|DarkAmber|DarkBlack|DarkBlack1|DarkBlue|DarkBlue1|DarkBlue10|DarkBlue11|DarkBlue12|DarkBlue13|DarkBlue14|DarkBlue15|DarkBlue16|DarkBlue17|DarkBlue2|DarkBlue3|DarkBlue4|DarkBlue5|DarkBlue6|DarkBlue7|DarkBlue8|DarkBlue9|DarkBrown|DarkBrown1|DarkBrown2|DarkBrown3|DarkBrown4|DarkBrown5|DarkBrown6|DarkBrown7|DarkGreen|DarkGreen1|DarkGreen2|DarkGreen3|DarkGreen4|DarkGreen5|DarkGreen6|DarkGreen7|DarkGrey|DarkGrey1|DarkGrey10|DarkGrey11|DarkGrey12|DarkGrey13|DarkGrey14|DarkGrey2|DarkGrey3|DarkGrey4|DarkGrey5|DarkGrey6|DarkGrey7|DarkGrey8|DarkGrey9|DarkPurple|DarkPurple1|DarkPurple2|DarkPurple3|DarkPurple4|DarkPurple5|DarkPurple6|DarkPurple7|DarkRed|DarkRed1|DarkRed2|DarkTanBlue|DarkTeal|DarkTeal1|DarkTeal10|DarkTeal11|DarkTeal12|DarkTeal2|DarkTeal3|DarkTeal4|DarkTeal5|DarkTeal6|DarkTeal7|DarkTeal8|DarkTeal9|Default|Default1|DefaultNoMoreNagging|Green|GreenMono|GreenTan|HotDogStand|Kayak|LightBlue|LightBlue1|LightBlue2|LightBlue3|LightBlue4|LightBlue5|LightBlue6|LightBlue7|LightBrown|LightBrown1|LightBrown10|LightBrown11|LightBrown12|LightBrown13|LightBrown2|LightBrown3|LightBrown4|LightBrown5|LightBrown6|LightBrown7|LightBrown8|LightBrown9|LightGray1|LightGreen|LightGreen1|LightGreen10|LightGreen2|LightGreen3|LightGreen4|LightGreen5|LightGreen6|LightGreen7|LightGreen8|LightGreen9|LightGrey|LightGrey1|LightGrey2|LightGrey3|LightGrey4|LightGrey5|LightGrey6|LightPurple|LightTeal|LightYellow|Material1|Material2|NeutralBlue|Purple|Python|Reddit|Reds|SandyBeach|SystemDefault|SystemDefault1|SystemDefaultForReal|Tan|TanBlue|TealMono|Topanga", description: "PySimpleGUI window theme", help: "Requires a restart to take effect." }
# priority: core
# classifiers: x11, http
#
# The main window binds all processing logic together. Lists
# primarily the SecRules and their states (depending on the
# selected vhost/*.conf file). Then allows to search through
# logs to find potential false positives.
#



import sys, os, re, json, subprocess
from modseccfg import utils, appsettings, icons, vhosts, logs, writer
from modseccfg.recipe import recipe
import PySimpleGUI as sg
sg.theme(appsettings.conf["theme"])

#-- init
rule_tree = sg.TreeData()
vhosts.scan_all()


#-- prepare vhost/rules/logs for UI structures
class ui_data:

    @staticmethod
    def rules(log_count={}, rulestate={}):
        rule_tree = sg.TreeData()
        hidden = [0]
        for id,r in vhosts.rules.items():
            # skip control rules
            if r.pattern == "@eq 0" or r.vars == "TX:EXECUTING_PARANOIA_LEVEL":
                hidden.append(id)
                continue
            parent = ""
            if r.chained_to:
                parent = r.chained_to
                if parent in hidden:
                    continue
            # prepare treedata attributes
            state = rulestate.get(id)
            if state in (0, "off"):
                state = "❌"
            elif state in (-1, "change"):
                state = "➗"
            else:
                state = "✅"
            msg = r.msg or r.params.get("logdata") or "{}     {}".format(r.vars, r.pattern)
            rule_tree.insert(
                parent=parent,
                key=id,
                text=id,
                values=[
                   state, str(id), msg, r.tag_primary, log_count.get(id, 0)
                ],
                icon=icons.vice #ui_data.img_vice
            )
        return rule_tree

#-- widget structure
layout = [
    [sg.Column([
            # menu
            [sg.Menu([
                    ['File', ['Rescan configs', 'Rescan logs', 'Test', 'Settings', 'Exit']],
                    ['Rule', ['Disable', 'Modify', 'Enable']],
                    ['Recipe', ['<Location>', '<FilesMatch>', '<Directory>', "Exclude parameter", "ConvertToRewriteRule"]],
                    ['Help', ['Advise']]
                ], key="menu"
            )],
            # button row
            [
                sg.Button("❌ Disable"),
                sg.Button("➗ Modify",disabled=1),
                sg.Button("✅ Enable"),
                sg.Button("❮❯ Wrap",disabled=1)
            ],
            # comboboxes
            [sg.Text("vhost/conf", font="bold"),
             sg.Combo(key="confn", size=(50,1), values=vhosts.list_vhosts(), enable_events=True),
             sg.Text("Log"),
             sg.Combo(key="logfn", values=logs.find_logs(), size=(30,1), enable_events=True),
             ],
        ]),
        # logo
        sg.Column([ [sg.Image(data=icons.logo)] ], element_justification='r', expand_x=1),
    ],
    # tabs
    [sg.TabGroup([[
        # rule
        sg.Tab("   SecRules                                                                        ", [[
            sg.Tree(
                key="rule", data=ui_data.rules(), headings=["❏","RuleID","Description","Tag","Count"],
                col0_width=0, col_widths=[1,10,65,15,10], max_col_width=500,
                justification="left", show_expanded=0, num_rows=30, auto_size_columns=False
            )
            #], expand_x=1, expand_y=1, size=(600,500))
        ]]),
        # log
        sg.Tab("  Log                                             ", [[
            sg.Listbox(values=["... 403", "... 500"], size=(980,650), key="log")
        ]])
    ]], key="active_tab")],
    [sg.Text("...", key="status")]
]



#-- GUI event loop and handlers
class gui_event_handler:

    # prepare window
    def __init__(self):
        self.w = sg.Window(title="mod_security config", layout=layout, size=(1200,775), font="Sans 12", resizable=1)
        self.tab = "secrules"
        self.status = self.w["status"].update
        self.vh = None

    # event loop and function/module dispatching
    def main(self):
        while True:
            event, data = self.w.read()
            if event == sg.WIN_CLOSED:
                event, data = "exit", {}
            event = self._case(data.get("menu") or event)
            self.tab = self._case(data.get("active_tab", ""))

            if event and hasattr(self, event):
                getattr(self, event)(data)
            elif recipe.has(event):
                recipe.show(event, data)
            elif event == "exit":
                break
            else:
                #self.status(value=
                print("UNKNOWN EVENT: {} / {}".format(event, str(data)))
        self.w.close()
    
    # change in vhost combobox
    def confn(self, data):
        self.vh = vhosts.vhosts.get( data.get("confn") )
        logfn = data.get("logfn")
        # switch logfn + automatically scan new error.log?
        self.update_rules()

    # scan/update log
    def logfn(self, data):
        self.w["log"].update(
            logs.scan_log(data["logfn"])
        )
        self.update_rules()

    # SecRuleDisableById
    def disable(self, data):
        id = data["rule"]
        if id:
            id = id[0]
        else:
            return
        fn = data["confn"]
        if fn and id:
            writer.append(fn, "SecRuleDisableById", id)
            self.vh.ruledecl[id] = 0
            self.update_rules()

    # remap 'settings' event to pluginconf window
    def settings(self, data):
        appsettings.window(self)

    # renew display of ruletree with current log and vhost rulestate
    def update_rules(self):
        if self.vh:
            self.w["rule"].update(ui_data.rules(log_count=logs.log_count, rulestate=self.vh.rulestate))

    # remove non-alphanumeric characters (for event buttons / tab titles / etc.)
    def _case(self, s):
        return re.sub("\W+", "_", str(s)).strip("_").lower()
        
    # tmp/dev
    def test(self, data):
        print("No test code")

            

#-- main
def main():
    gui_event_handler().main()


Added 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
# 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.
#


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



class recipe:

    location = """
      <Location "/app/">
        SecRuleDisableById $id   #@wrap
      </Location>
    """
    
    exclude_parameter = """
       SecRuleUpdateTargetByID $id "!ARGS:param"
    """
    
    macros = """
    <IfModule mod_alias.c>
      <Macro SecRuleDisableByPath $id $path>
        SecRule REQUEST_URI "@eq $path" "id:%{md5:$id-$path},t:none,msg:'Whitelist $path',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="Recipe '{}'".format(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 …
        pass

Added modseccfg/utils.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
# encoding: utf-8
# api: python
#
# Monkeypatching some Python modules, and some convenience wrappers


#-- path
from pathlib import Path as path
def expandpath(dir):
    return str(path(dir).expanduser())


#-- patch re
import re
def re_compile(regex, *kargs, **kwargs):
    if type(regex) is str:
        regex = re.sub('\\\\h', '[\\ \\t\\f]', regex)
    return re.compile_orig(regex, *kargs, **kwargs)
re.compile_orig = re.compile
re.compile = re_compile

def re_grep(regex, list):
    return (s for s in list if re.search(regex, s))
re.grep = re_grep


#-- import frosch for prettier exceptions
try:
    import frosch
    frosch.hook()
except:
    pass

Added modseccfg/vhosts.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
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# api: modseccfg
# encoding: utf-8
# title: *.conf scanner
# description: Compiles a list of relevant apache/vhost files and Sec* settings
# type: tokenizer
# category: apache
# version: 0.5
# config:
#    { name: envvars, value: "/etc/default/apache", type: str, description: "Look up APACHE_ENV vars from shell script", help: "Mostly applies to Debian derivates. Other distros usually embed SetEnv directives for log paths." }
# license: ASL
#
# Runs once to scan for an vhost* and mod_security config files.
# Uses `apache2ctl -t -D DUMP_INCLUDES` to find all includes,
# and regexes for Sec*Rules or *Log locations and ServerNames.
#
# This should yield any mod_security and vhost-relevant sections.
# The list is kept in `vhosts`. And secrule declarations+options
# in `rules`.
#
# Extraction is fairly simplistic, but for this purpose we don't
# need an exact representation nor nested structures. The UI will
# present vhost/conf files rather than <VirtualHost> sections.
# (We shouldn't penalize the average user for random edge cases.)
# 
# Notably this will not work with mod_security v3, since the
# SecRules/Flags have been moved into matryoshka directives and
# external *.conf files (no JSON rulesets for some reason).
# Still doable, but not a priority right now. Same for nginx.
#


import os, re
import subprocess
import traceback
from pprint import pprint


# collected config/vhost sections
vhosts = {
    # fn → vhost:
           # .fn .t .name .logs[] .cfg{} .rulestate{} .ruledecl{} .linemap{}
}
# and SecRules (we don't actually use all extracted details)
rules = {
    # id → secrule:
           #  .id .chained_to .msg .flags{} .params{} .tags[] .tag_primary .ctl{} .setvar{} .vars .pattern
}


# extraction patterns
class rx:
    dump_includes = re.compile("^\s*\([\d*]+\)\s+(.+)$", re.M)
    # directives we care about
    interesting = re.compile(
        "^ \s* (ErrorLog | CustomLog | Server(Name|Alias) | (Virtual)?DocumentRoot | Sec\w* | Use\sSec\w+ | modsecurity\w* ) \\b",
        re.M|re.I|re.X
    )
    # extract directive line including line continuations (<\><NL>)
    configline = re.compile(
        """ ^
        [\ \\t]*                          # whitespace \h*
        # (?:Use \s{1,4})?                  # optional: `Use␣` to find custom macros like `Use SecRuleDisableByPath…`
        (\w+)                             # alphanumeric directive 
          [\ \\t]+                        # whitespace \h+
        (
          (?: [^\\n\\\\]+ | [\\\\]. )*    # literals, or backslash + anything
        )
        $ """,
        re.M|re.S|re.X
    )
    # to strip <\><NL>
    escnewline = re.compile(
        """[\\\\][\\n]\s*"""              # escaped linkebreaks
    )
    # handle quoted/unquoted directive arguments (not entirely sure if Apache does \" escaped quotes within)
    split_args = re.compile(
        """
        (?:\s+)   |                       # skip whitespace (\K not supported in python re, so removing empty matches in postprocessing)
        \#.*$  |                          # skip trailing comment (which isn't technically allowed, but)
        " ((?:[^\\\\"]+|\\\\ .)+) "  |    # quoted arguments
        (?!\#) ([^"\s]+)                  # plain arguments (no quotes, no spaces)
        """,
        re.X
    )
    # SecRule … … `actions:argument,…`
    actions = re.compile(
        """
        (?: (t|pfx) :)? (\w+)             # action
        (?:
            :                             # : value…
            ([^,']+)     |                # bareword
            :
            ' ([^']+) '                   # ' quoted '
        )?
        """,
        re.X
    )
    # line number scan: roughly look for id:123456 occurences
    id_num = re.compile(
        "id:(\d+)"                        # without context
    )
    # comment lookup, directly preceeding id, uncompiled 
    rule_comment = """
        ( (?:^\#.+$ | ^\s*\n)+ )          # consecutive comment lines
        [\s\S]+?                          # minimal amount of anything
        id:{}                             # id:nnnnn, requires "…{}…".format($id)
    """
    
    

# temporary state variables
class tmp:
    last_rule_id = 0

    tag_prio = ['event-correlation', 'anomaly-evaluation', 'OWASP_CRS/LEAKAGE/ERRORS_IIS', 'OWASP_CRS/LEAKAGE/SOURCE_CODE_PHP', 'OWASP_CRS/LEAKAGE/ERRORS_PHP', 'OWASP_CRS/LEAKAGE/ERRORS_JAVA', 'OWASP_CRS/LEAKAGE/SOURCE_CODE_JAVA', 'platform-sybase', 'platform-sqlite', 'platform-pgsql', 'platform-mysql', 'platform-mssql', 'platform-maxdb', 'platform-interbase', 'platform-ingres', 'platform-informix', 'platform-hsqldb', 'platform-frontbase', 'platform-firebird', 'platform-emc',
    'platform-db2', 'platform-oracle', 'CWE-209', 'OWASP_CRS/LEAKAGE/ERRORS_SQL', 'platform-msaccess', 'OWASP_CRS/LEAKAGE/SOURCE_CODE_CGI', 'PCI/6.5.6', 'WASCTC/WASC-13', 'OWASP_CRS/LEAKAGE/INFO_DIRECTORY_LISTING', 'attack-disclosure', 'OWASP_CRS/WEB_ATTACK/JAVA_INJECTION', 'language-java', 'CAPEC-61', 'WASCTC/WASC-37', 'OWASP_CRS/WEB_ATTACK/SESSION_FIXATION', 'attack-fixation', 'OWASP_AppSensor/CIE1', 'WASCTC/WASC-19', 'OWASP_CRS/WEB_ATTACK/SQL_INJECTION', 'attack-sqli',
    'PCI/6.5.1', 'OWASP_TOP_10/A2', 'CAPEC-63', 'platform-internet-explorer', 'platform-tomcat', 'CAPEC-242', 'OWASP_AppSensor/IE1', 'OWASP_TOP_10/A3', 'WASCTC/WASC-22', 'WASCTC/WASC-8', 'OWASP_CRS/WEB_ATTACK/XSS', 'attack-xss', 'OWASP_CRS/WEB_ATTACK/NODEJS_INJECTION', 'attack-injection-nodejs', 'language-javascript', 'OWASP_CRS/WEB_ATTACK/PHP_INJECTION', 'attack-injection-php', 'language-php', 'language-powershell', 'PCI/6.5.2', 'WASCTC/WASC-31',
    'OWASP_CRS/WEB_ATTACK/COMMAND_INJECTION', 'attack-rce', 'platform-unix', 'language-shell', 'OWASP_CRS/WEB_ATTACK/RFI', 'attack-rfi', 'PCI/6.5.4', 'OWASP_TOP_10/A4', 'WASCTC/WASC-33', 'OWASP_CRS/WEB_ATTACK/FILE_INJECTION', 'OWASP_CRS/WEB_ATTACK/DIR_TRAVERSAL', 'attack-lfi', 'OWASP_CRS/WEB_ATTACK/HTTP_PARAMETER_POLLUTION', 'CAPEC-460', 'OWASP_CRS/WEB_ATTACK/HEADER_INJECTION', 'OWASP_CRS/WEB_ATTACK/RESPONSE_SPLITTING', 'OWASP_CRS/WEB_ATTACK/REQUEST_SMUGGLING',
    'paranoia-level/4', 'language-aspnet', 'paranoia-level/3', 'OWASP_CRS/PROTOCOL_VIOLATION/MISSING_HEADER_UA', 'OWASP_CRS/POLICY/HEADER_RESTRICTED', 'OWASP_CRS/POLICY/EXT_RESTRICTED', 'OWASP_CRS/POLICY/PROTOCOL_NOT_ALLOWED', 'OWASP_CRS/PROTOCOL_VIOLATION/CONTENT_TYPE_CHARSET', 'OWASP_CRS/POLICY/CONTENT_TYPE_NOT_ALLOWED', 'OWASP_AppSensor/EE2', 'OWASP_TOP_10/A1', 'WASCTC/WASC-20', 'OWASP_CRS/PROTOCOL_VIOLATION/CONTENT_TYPE', 'OWASP_CRS/POLICY/SIZE_LIMIT',
    'OWASP_CRS/PROTOCOL_VIOLATION/IP_HOST', 'OWASP_CRS/PROTOCOL_VIOLATION/EMPTY_HEADER_UA', 'OWASP_CRS/PROTOCOL_VIOLATION/MISSING_HEADER_ACCEPT', 'OWASP_CRS/PROTOCOL_VIOLATION/MISSING_HEADER_HOST', 'platform-windows', 'platform-iis', 'OWASP_CRS/PROTOCOL_VIOLATION/EVASION', 'OWASP_CRS/PROTOCOL_VIOLATION/INVALID_HREQ', 'CAPEC-272', 'OWASP_CRS/PROTOCOL_VIOLATION/INVALID_REQ', 'attack-protocol', 'OWASP_CRS/AUTOMATION/CRAWLER', 'attack-reputation-crawler',
    'OWASP_CRS/AUTOMATION/SCRIPTING', 'attack-reputation-scripting', 'PCI/6.5.10', 'OWASP_TOP_10/A7', 'WASCTC/WASC-21', 'OWASP_CRS/AUTOMATION/SECURITY_SCANNER', 'attack-reputation-scanner', 'paranoia-level/2', 'attack-dos', 'PCI/12.1', 'OWASP_AppSensor/RE1', 'OWASP_TOP_10/A6', 'WASCTC/WASC-15', 'OWASP_CRS/POLICY/METHOD_NOT_ALLOWED', 'OWASP_CRS', 'IP_REPUTATION/MALICIOUS_CLIENT', 'attack-reputation-ip', 'platform-multi', 'attack-generic', 'platform-apache', 'language-multi',
    'application-multi', 'paranoia-level/1']

    env = {
        "APACHE_LOG_DIR": "/var/log/apache2"  #/var/log/httpd/
    }
    env_locations = [
        "/etc/apache2/envvars", "/etc/default/httpd"
    ]


# encapsulate properties of config file (either vhosts, SecCfg*, or secrule collections)
class vhost:

    # split *.conf directives, dispatch onto handlers
    def __init__(self, fn, src):

        # vhost properties
        self.fn = fn           # retain filename
        self.t = "cfg"         # *.conf type (rules|vhost|cfg)
        self.name = ""         # ServerName
        self.logs = []         # error.log/access.log
        self.cfg = {}          # SecRuleEngine/settings
        self.rulestate = {}    # SecRuleDisableById
        self.ruledecl = {}     # SecRule IDs → rules{}
        self.linemap = {}      # LineNo → RuleID (for looking up chained rules in error.log)
        self.mk_linemap(src)   # id → source line
        self.xincludes = []    # modsec v3 includes
        
        # extract directive lines
        for dir,args  in rx.configline.findall(src):    # or .finditer()? to record positions right away?
            dir = dir.lower()
            #print(dir, args)
            if hasattr(self, dir):
                # src.find(…) + str position to linecounter... / or do a separate line indexer scan (tx.msg:… -> id:…)
                func = getattr(self, dir)
                func(self.split_args(args))
            elif dir.startswith("sec"):
                self.cfg[dir] = args
        # determine config file type
        if self.name:
            self.t = "vhost"
        elif len(self.rulestate) >= 5:
            self.t = "cfg"
        elif len(self.ruledecl) >= 5:
            self.t = "rules"

    # strip \\ \n line continuations, split all "args"
    def split_args(self, args):
        args = re.sub(rx.escnewline, " ", args)
        args = rx.split_args.findall(args)
        args = [s[1] or s[0] for s in args]
        args = [s for s in args if len(s)]
        #args = [s.decode("unicode_escape") for s in args]   # don't strip backslashes
        return args
    # apply ${ENV} vars
    def var_sub(self, s):
        return re.sub('\$\{(\w+)\}', lambda m: tmp.env.get(m.group(1), ""), s)

    # apache: log directives
    def errorlog(self, args):
        self.logs.append(self.var_sub(args[0]))
    def customlog(self, args):
        self.logs.append(self.var_sub(args[0]))
    def servername(self, args):
        self.name = args[0]

    # modsec: create a rule{}
    def secrule(self, args):
        last_id = int(tmp.last_rule_id)
        r = secrule(args)
        if r.id:
            tmp.last_rule_id = r.id
        elif rules.get(last_id) and "chain" in rules[last_id].flags:
            tmp.last_rule_id = round(tmp.last_rule_id + 0.1, 1)
            r.id = tmp.last_rule_id
            r.chained_to = int(last_id) # primary parent
        rules[r.id] = self.ruledecl[r.id] = r
        #print(r.__dict__)

    # modsec: just a secrule without conditions
    def secaction(self, args):
        self.secrule(["@SecAction", "setvar:", args[0]])
    
    # modsec: SecRuleDisableById 900001 900002 900003
    def secruledisablebyid(self, args):
        for a in args:
            if re.match("^\d+-\d+$", a):   # are ranges still allowed?
                a = [int(x) for x in a.split("-")]
                for i in range(*a):
                    if i in rules:    # only apply state for known/existing rules, not the whole range()
                        self.rulestate[i] = 0
            elif re.match("^\d+$", a):
                self.rulestate[int(a)] = 0
            else:
                self.rulestate[a] = 0  # from tag

    # modsec: SecRuleDisableByTag sqli app-name - maps onto .secruledisablebyid
    def secruledisablebytag(self, args):
        self.secruledisablebyid(args)

    # these need to be mapped onto existing rules (if within t==cfg)
    # · SecRuleUpdateTargetById
    # · SecRuleUpdateActionById

    # modssec: irrelevant (not caring about skipAfter rules)
    def secmarker(self, args):
        pass

    # v3-connector: Include
    def modsecurity_rules_file(self, args):
        raise Exception("modsecurity v3 connector rules not supported (module doesn't provide disclosure of custom includes via `apache2ctl -t -D DUMP_INCLUDES` yet)")
        #vhosts[fn] = vhost(args[0], open(args[0], "r", encoding="ascii").read())
    
    # apache: define ENV var
    def define(self, args):
        tmp.env[args[0]] = args[1]
    
    # map rule ids to line numbers
    def mk_linemap(self, src):
        for i,line in enumerate(src.split("\n")):
            id = rx.id_num.search(line)
            if id:
                self.linemap[i] = int(id.group(1))
    # find closest match
    def line_to_id(self, lineno):
        if not lineno in self.linemap:
            lines = [i for i in sorted(self.linemap.keys()) if i <= lineno]
            if lines and lines[-1] in self.linemap:
                self.linemap[lineno] = self.linemap.get(lines[-1])
        return self.linemap.get(lineno, 0)
        
        

# break up SecRule definition into parameters, attributes (id,msg,tags,meta,actions etc.)
class secrule:
    
    def __init__(self, args):
        # secrule properties
        self.id = 0
        self.chained_to = 0
        self.msg = ""
        self.flags = []
        self.params = {}
        self.tags = []
        self.tag_primary = ""
        self.ctl = {}
        self.setvar = {}
        self.vars = "REQ*"
        self.pattern = "@rx ..."
        # args must contain 3 bits for a relevant SecRule
        if len(args) != 3:
            print("UNEXPECTED NUMBER OF ARGS:", args)
            return
        self.vars, self.pattern, actions = args
        #print(args)
        # split up actions,attributes:…
        for pfx, action, value, qvalue in rx.actions.findall(actions):
            #print(pfx,action,value,qvalue)
            self.assign(pfx, action, value or qvalue)
        # most specific tag
        for p in tmp.tag_prio:
            if p in self.tags:
                self.tag_primary = p
                break
        # if SecAction (uncoditional rule, mostly setvars:)
        if self.vars == "@SecAction" and not self.msg:
            self.msg = "@SecAction {}".format(str(self.setvar) if self.setvar else str(self.params))

    # distribute actions/attributes into properties here
    def assign(self, pfx, action, value):
        if action == "id":
            self.id = int(value)
        elif action == "msg":
            self.msg = value
        elif action == "tag":
            self.tags.append(value)
        elif action == "ctl":
            if value.find("=") > 0:
                action, value = value.split("=", 1)
            self.ctl[action] = value or 1
        elif action == "setvar":
            if value.find("=") > 0:
                action, value = value.split("=", 1)
            self.setvar[action] = value
        elif pfx == "t" and not value:
            self.flags.append(pfx+":"+action)
        elif action and not pfx:
            if value:
                self.params[action] = value
            else:
                self.flags.append(action)
        else:
            print("  WHATNOW? ", [pfx, action, value])
    
    # look up doc comment in source file
    def help(self):
        id = int(self.id)
        for fn,vh in vhosts.items():
            if id in vh.ruledecl:
                src = open(fn, "r").read().decode("utf-8")
                comment = re.findall(rx.rule_comment.format(str(id)), src, re.X|re.M)
                if comment:
                    comment = re.sub("^\#\s?", "", comment[0], re.M)
                    return comment
                break
        return "No documentation comment present"
    


# scan for APACHE_ENV= vars
def read_env_vars():
    for fn in tmp.env_locations:
        if os.path.exists(fn):
            src = open(fn, "r").read()
            tmp.env.update(dict(re.findall("""^\s*(?:export\s+)?([A-Z_]+)=["']?([\w/\-.]+)""", src, re.M)))

# iterate over all Apache config files, visit relevant ones (vhosts/mod_security configs)
def scan_all():
    read_env_vars()
    for fn in apache_dump_includes():
        src = open(fn, "r").read() #.decode("utf-8")
        if rx.interesting.search(src):
            vhosts[fn] = vhost(fn, src)

# get *.conf list from apache2ctl
def apache_dump_includes():
    stdout = subprocess.Popen(["apache2ctl", "-t", "-D", "DUMP_INCLUDES"], stdout=subprocess.PIPE).stdout
    return rx.dump_includes.findall(stdout.read().decode("utf-8"))

# just used once    
def count_tags():
    import collections
    tags = []
    for fn,v in rules.items():
        if v.tags:
            tags = tags + v.tags
    print(list(reversed(list(collections.Counter(tags).keys()))))
    
# prepare list of names for mainwindow vhosts/conf combobox
def list_vhosts(types=["cfg","vhost"]):
    return [k for k,v in vhosts.items() if v.t in types]



# initialization (scan_all) is done atop mainwindow

#scan_all()
#count_tags()
#pprint(vhosts)
#print({k:pprint(v.__dict__) for k,v in vhosts.items()})

Added modseccfg/writer.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
# encoding: utf-8
# api: modseccfg
# title: Writer
# description: updates *.conf files with new directives
# version: 0.1
# type: file
# category: config
# config:
#     { name: write_etc, type: bool, value: 0, description: "Write to /etc without extra warnings", help: "Normally modseccfg would not update default apache/modsecurity config files." }
#     { name: write_sudo, type: bool, value: 0, description: "Use sudo to update non-writable files", help: "Run `sudo` on commandline to update files, if permissions insufficient" }
# state: alpha
#
# Reads, updates and then writes back configuration files.
# Contains multiple functions for different directives.
# Some need replacing, while others (lists) just need to be
# appended.
# 


import os, re
from modseccfg import vhosts, appsettings, utils
import PySimpleGUI as sg


class rx:
    pfx = re.compile("""
        ^(\s*)\w+
    """, re.M)
    end = re.compile("""
        \Z | ^\s*</VirtualHost>
    """, re.M)


# read src from config file
def read(fn):
    return open(fn, "r", encoding="utf8").read()

# update file
def write(fn, src):
    if not appsettings.conf["write_etc"] and re.search("^/etc/|^/usr/share/", fn):# and not re.search("/sites|/crs-setup.conf", fn):
        # alternatively check for `#editable:1` header with pluginconf
        if sg.popup_yes_no("Default Apache/mod_sec config file '{}' should not be updated. Proceed anyway?".format(fn)) != "Yes":
            return
    if not os.access(fn, os.W_OK):
        sg.popup_cancel("Config file '{}' isn't writeable. (Use chown/chmod to make it so.)".format(fn))
        # elif appsettings.conf["write_sudo"]: write_sudo(fn, src)
        return
    open(fn, "w", encoding="utf8").write(src)

# write to file via sudo/tee pipe instead
def write_sudo(fn, src):
    p = subprocess.Popen(['sudo', 'tee'], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    p.stdin.write(src.encode("utf-8"))
    p.stdin.close()
    print(p.stdout.read())
    p.wait()

# detect leading whitespace
def pfx(src):
    space = rx.pfx.findall(src)
    if space:
        return space[0]
    else:
        return ""

# updates `src` on existing occurence of directive, else appends
def augment(src, directive, value):
    pass

# doesn't look for context
def append(fn, directive, value):
    src = read(fn)
    src = rx.end.sub("{} {}".format(directive, value), src)
    write(fn, src)


Added requirements.txt.







>
>
>
1
2
3
pysimplegui
python3-tk
pluginconf

Added setup.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
#!/usr/bin/env python3
# encoding: utf-8
# api: pip
# type: build
# title: config for setuptools
#
# Always prefer setuptools over distutils
#

from pluginconf.setup import setup

setup(
    fn="modseccfg/__init__.py",
    name="modseccfg",
    package_dir={"": "."},
    package_data={},
    data_files=[],
    entry_points={
        "console_scripts": [
            "modseccfg=modseccfg.mainwindow:main",
        ]
    }
)