GUI editor to tame mod_security rules

⌈⌋ branch:  modseccfg


Artifact [89402584fc]

Artifact 89402584fc75c267597e02ac5814dfb2dcd3ed2c8ad8af2922d427d5c31bdc9a:

  • File modseccfg/mainwindow.py — part of check-in [393a1fb162] at 2020-11-26 00:26:03 on branch trunk — Release as 0.4.0 (user: mario size: 14950)

# encoding: utf-8
# api: python
# type: main
# title: main window
# description: GUI with menus, actions, rules and logs
# category: config
# version: 0.4.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." }
#    { name: switch_auto, type: bool, value: 0, description: "Automatically switch to matching error.log when selecting vhost" }
#    { name: keyboard_binds, type: bool, value: 1, description: "Enable keyboard shortcuts in main window", help: "F1=info, F3/F4=editor, F5=log-viewer, F12=settings" }
# 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, icons, vhosts, logs, writer, editor, ruleinfo, recipe
from modseccfg.utils import srvroot, conf
import tkinter as tk, PySimpleGUI as sg
sg.theme(conf["theme"])

#-- init
vhosts.scan_all()


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

    @staticmethod
    def rules(log_count={}, rulestate={}):
        rule_tree = sg.TreeData()
        hidden = [0]
        for id,r in vhosts.rules.items():
            # skip control rules
            if r.hidden:
                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, "✅")  # formerly: -1=➗, 0=❌, 1=, undef=✅
            rule_tree.insert(
                parent=parent,
                key=id,
                text=id,
                values=[
                   state, str(id), r.msg, r.tag_primary, log_count.get(id, 0)
                ],
                icon=icons.vice #ui_data.img_vice
            )
        return rule_tree

    #-- @decorators for mainwindow
    def needs_confn(func):
        def mask(self, data):
            if not data.get("confn"):
                return self.status("Needs config filename selected")
            func(self, data)
        return mask
    def needs_vhost(func):
        def mask(self, data):
            if not vhosts.vhosts.get(data.get("confn")):
                return self.status("Needs valid vhost.conf selected")
            func(self, data)
        return mask
    def needs_id(func):
        def mask(self, data):
            if not self.id:
                return self.status("Needs a rule selected")
            func(self, data)
        return mask


#-- widget structure
layout = [
    [sg.Column([
            # menu
            [sg.Menu([
                    ["File", ["Edit conf/vhost file (F4)", "---", "Settings (F12)", "SecEngine options", "CoreRuleSet options", "---", "Rescan configs", "Rescan logs", "Test", "---", "Exit"]],
                    ["Rule", ["Info (F1)", "Disable", "Enable", "Modify", "<Wrap>", "Masquerade"]],
                    ["Recipe", recipe.ls()],
                    ["Help", ["Advise"]]
                ], key="menu"
            )],
            # button row
            [
                sg.Button("⭐ Info"),
                sg.Button("❌ Disable"),
                sg.Button("✅ Enable"),
                sg.ButtonMenu("➗ Modify", ["Modify",["Actions","Vars/Target"]], disabled=1, k="btn_modify"),
                sg.Button("❮❯ Wrap",disabled=1)
            ],
            [sg.T(" ")],
            # 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.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,
                enable_events=False
            )
            #], expand_x=1, expand_y=1, size=(600,500))
        ], [sg.StatusBar("...", key="status", auto_size_text=1, size=(800,1))]
        ]),
        # log
        sg.Tab("  Log                                             ", [[
            sg.Pane(k="pane", border_width=0, relief="flat", orientation='v', pane_list=[
                sg.Column([[
                    sg.Listbox(values=["... 403", "... 500"], size=(980,400), key="log", enable_events=1)
                ]], size=(1900,500), k="col_log"),
                sg.Column([[
                    sg.Multiline(size=(220,5), key="logview")
                ]], size=(1900,120), k="col_logview")
            ])
        ]])
    ]], key="active_tab")],
]



#-- GUI event loop and handlers
class gui_event_handler:

    # prepare window
    def __init__(self):
        self.w = sg.Window(
            title=f"mod_security config {utils.srvroot.srv}", layout=layout, font="Sans 12",
            size=(1200,825), return_keyboard_events=conf["keyboard_binds"], resizable=1, icon=icons.icon
        )
        self.tab = "secrules"
        self.status = self.w["status"].update
        self.vh = None
        self.no_edit = [949110, 980130]
        self.win_map = {}
        self.w.read(timeout=1)
        # alias functions
        self.status = self.w["status"].update
        # layout adaptions
        self._window_resized({})  # set standard sizes (from 1900*650)
        self.w["menu"].Widget.bind("<Configure>", self._window_resized) # resize event
        #   .w["pane"].Widget
        print(type(self.w["rule"]))

    # we're actually listening to `pane`, then resize contained widgets when <Configure> event fired
    def _window_resized(self, e):
        x, y = 1200, 755 # 825
        if e:
            win = self.w["active_tab"].Widget#.master
            x, y = win.winfo_reqwidth(), win.winfo_reqheight()
        print("_window_resized:", x,y)
        self.w["rule"].expand(expand_row=int((y-210)/20))
        #self.w["log"].expand(expand_y=1)
        #xself.w["col_log"].expand(expand_y=1)
        self.w["col_log"].Widget.config(width=x, height=min(600,y-210+30))
        self.w["col_logview"].Widget.config(width=x, height=120)
        #self.w["rule"].update(num_rows =  floor((y - 130) / 17))
    
    
   # add to *win_map{} event loop
    def win_register(self, win, cb=None):
        if not cb:
            cb = lambda *e: win.close()
        self.win_map[win] = cb
        win.read(timeout=1)

    # demultiplex PySimpleGUI events across multiple windows
    def main(self):
        self.win_register(self.w, self.event)
        while True:
            win_ls = [win for win in self.win_map.keys()]
            #print(f"l:{len(win_ls)}")
            # unlink closed windows
            for win in win_ls:
                if win.TKrootDestroyed:
                    print("destroyed" + str(win))
                    del self.win_map[win]
            # all gone
            if len(win_ls) == 0:
                break
            # if we're just running the main window, then a normal .read() does suffice
            elif len(win_ls) == 1 and win_ls==[self.w]:
                self.event(*self.w.read())
            # poll all windows - sg.read_all_windows() doesn't quite work
            else:
                #win_ls = self.win_map.iteritems()
                for win in win_ls:
                    event, data = win.read(timeout=20)
                    if event and event != "__TIMEOUT__" and self.win_map.get(win):
                        self.win_map[win](event, data)
                    elif event == sg.WIN_CLOSED:
                        win.close()
        sys.exit()

    # mainwindow event dispatcher
    def event(self, event, data):
            
        # prepare common properties
        data = data or {}
        event = self._case(data.get("menu") or event)
        event = gui_event_handler.map.get(event, event)
        self.tab = self._case(data.get("active_tab", ""))
        self.id = (data.get("rule") or [0])[0]
        self.vh = vhosts.vhosts.get( data.get("confn") )

        # dispatch
        if event and hasattr(self, event):
            self.status("")
            getattr(self, event)(data)
        elif recipe.has(event):
            recipe.show(event, data)
        elif event == "exit":
            self.w.close()
        else:
            self.status(f"UNKNOWN EVENT: {event} / {data}")

    # alias/keyboard map
    map = {
        sg.WIN_CLOSED: "exit",
        "none": "exit",  # happens when mainwindow still in destruction process
        "f3_69": "log_view",
        "f4_70": "edit_conf_vhost_file",
        "f5_71": "log_view",
        "f12_96": "settings",
        "return_36": "info"
    }
    
    # change in vhost combobox
    def confn(self, data):
        # switch logfn + automatically scan new error.log?
        if conf["switch_auto"]:
            logfn = data.get("logfn")
            logs = re.grep("error", self.vh.logs)
            if len(logs):
                self.w["logfn"].update(value=logs[0])
                self.logfn(data=dict(logfn=logs[0]))
        self.update_rules()
        self.status(self.vh.warn)

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

    # add "SecRuleRemoveById {id}" in vhost.conf
    @ui.needs_id
    @ui.needs_confn
    def disable(self, data):
        if self.id in self.no_edit and self._cancel("This rule should not be disabled (it's a heuristic/collective marker). Continue?"):
            return
        if data["confn"] and self.id:
            writer.append(data["confn"], directive="SecRuleRemoveById", value=self.id, comment=" # "+vhosts.rules[self.id].msg)
            self._update_rulestate(self.id, 0)  # 0=disabled

    # remove any "SecRuleRemove* {id}" in vhost.conf
    @ui.needs_id
    @ui.needs_confn
    def enable(self, data):
        if self.vh and self.vh.rulestate.get(self.id) != 0 and self._cancel("SecRule might be wrapped/masked. Reenable anyway?"):
            return
        writer.remove_remove(data["confn"], "SecRuleRemoveById", self.id)
        self._update_rulestate(self.id, None)

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

    # editor
    @ui.needs_confn
    def edit_conf_vhost_file(self, data):
        editor.editor(data.get("confn"), register=self.win_register)

    # `log` listbox selection change: transfer entry to `logview` textbox
    def log(self, data):
        self.w["logview"].update(value=data["log"][0])
        # if conf["logview_colorize"]: … rx/split … .print(part, foreground=…)

    # "Log View (F3)" menu entry → editor
    def log_view(self, data):
        editor.editor(data.get("logfn"), readonly=1)

    # secrule details
    @ui.needs_id
    def info(self, data):
        if self.tab == "secrules":
            self.win_register(
                ruleinfo.show(self.id, log_values=self.w["log"].get_list_values)
            )
        else:
            print("No info() for "+self.tab)

    # SecOptions dialog
    @ui.needs_confn
    def secengine_options(self, data):
        import modseccfg.secoptions
        modseccfg.secoptions.window(data.get("confn", "/etc/modsecurity/modsecurity.conf"))

    # CRS setvar dialog
    @ui.needs_confn
    def coreruleset_options(self, data):
        import modseccfg.crsoptions
        modseccfg.crsoptions.window(data.get("confn", "/etc/modsecuritye/crs/crs-setup.conf"))

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

    # called from disable/enable to set 0=disabled, 1=masked, None=enabled, etc
    def _update_rulestate(self, id, val):
        if self.vh:
            if val==None and id in self.vh.rulestate:
                del self.vh.rulestate[id]
            else:
                self.vh.rulestate[id] = val
            self.update_rules()

    # remove non-alphanumeric characters (for event buttons / tab titles / etc.)
    def _case(self, s):
        return re.sub("\(?\w+\)|\W+|_0x\w+$", "_", str(s)).strip("_").lower()

    # set mouse pointer ("watch" for planned hangups)
    def _cursor(self, s="arrow"):
        self.w.TKroot.config(cursor=s)
        self.w.read(timeout=1)
    
    def _cancel(self, text):
        return sg.popup_yes_no(text) == "No"
        
    # tmp/dev
    def test(self, data):
        print("No test code")

            

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