GUI editor to tame mod_security rules

⌈⌋ ⎇ branch:  modseccfg


Artifact [c4ef8c1a63]

Artifact c4ef8c1a63b0a33855638320fd85a274425f47059e9e210cbee0a40629b2bad0:

  • File modseccfg/writer.py — part of check-in [a7de0f8780] at 2022-10-21 22:50:25 on branch trunk — comment fixes (user: mario size: 6108)

# encoding: utf-8
# api: modseccfg
# title: Writer
# description: updates *.conf files with new directives
# version: 0.7
# 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" }
#     { name: backup_files, value: 1, type: bool, description: "Copy files to ~/backup-config/ before rewriting" }
#     { name: backup_dir, value: "~/backup-config/", type: str, description: "Where to store copies of configuration files" }
# 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, time, shutil
from modseccfg import vhosts, utils, recipe
from modseccfg.utils import srvroot, conf, log
import PySimpleGUI as sg


class rx:
    pfx = re.compile(r"""
        ^(\h*)\w+
    """, re.X|re.M)
    end = re.compile(r"""
        ^(?=\s*</VirtualHost>) | \Z
    """, re.X|re.M)
    end_preconf = re.compile(r"""
        ^(?=\s*</(Directory|VirtualHost)>) | \Z
    """, re.X|re.M)
    comments = re.compile("^\s*#.+", re.M)
    preconf_directives = re.compile(r"""
       ctl:removeBy\w+= | ctl:disable\w+= | ctl:ruleEngine= | ctl:auditEngine= | ^\h*Use\h*Sec\w+
    """, re.X|re.M)


#-- file I/O --

# read src from config file
def read(fn):
    return srvroot.read(fn)

# update file
def write(fn, src):
    is_system_file = re.search("^/etc/|^/usr/share/", fn) and not re.search("/sites-enabled/|/crs-setup.conf|RE\w+-\d+-EXCLUSION", fn)
    if is_system_file and not conf.write_etc:
        # alternatively check for `#editable:1` header with pluginconf
        if sg.popup_yes_no(f"Default Apache/mod_sec config file '{fn}' should not be updated. Proceed anyway?") != "Yes":
            return
    if not srvroot.writable(fn):
        sg.popup_cancel(f"Config file '{fn}' isn't writeable. (Use chown/chmod to make it so.)")
        # elif conf.write_sudo: write_sudo(fn, src)
        return
    # save a copy before doing anything else
    if conf.backup_files:
        backup(fn)
    # actually write
    srvroot.write(fn, src)

# copy *.conf to ~/backup-config/
def backup(fn):
    dir = utils.expandpath(conf.backup_dir)
    os.makedirs(dir, 0o751, True)
    dest = re.sub("[^\w\.\-\+\,]", "_", fn)
    dest = f"{dir}/{time.time()}.{dest}"
    shutil.copyfile(srvroot.fn(fn), dest)

# 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 if changes should go to vhost.*.preconf
# this will probably just be used by recipes to swap out the target filename
def fn_preconf(fn, addsrc, force_preconf=False, create=True):
    # test if we should act at all
    if force_preconf:
        pass
    elif not rx.preconf_directive.search(rx.comments.sub("", addsrc)):
        return fn
    elif not conf.get("preconf"):
        return fn
    elif not vhosts.tmp.decl_preconf:
        log.writer.warn("*.preconf includes don't seem to be configured.")
    # substitute any standard file extensions with .preconf
    new_fn = re.sub("\.(preconf|conf|dir|cfg|inc|load|vhost)$", ".preconf", fn)
    if not new_fn.endswith(".preconf") and fn == new_fn:
        new_fn += ".preconf"
    # create stub
    if create and not srvroot.exists(newfn):
        log.writer.info("Creating new preconf stub", new_fn) 
        vh = vhosts.vhost.get(fn)
        if vh and vh.name:
            src = recipe.templates.Setup["preconf_stub"]
            src = src.format({
                "DocumentRoot": vh.cfg.get("documentroot", f"/www/{vh.name}/"),
                "ServerName": vh.name
            })
            srvroot.write(new_fn, src)
    return new_fn

# use either rx.end or rx.end_preconf (looking for </Dir> instead of </VH)
def rx_end_preconf(fn):
    if fn.endswith(".preconf"):
        return rx.end_preconf
    else:
        return rx.end

# detect leading whitespace
def pfx(src):
    space = rx.pfx.findall(src)
    if space:
        return space[0]
    else:
        return ""
# add leading space to all lines of insert block
def prepend_pfx(add, pfx=""):
    if pfx:
        add = re.sub("^", pfx, add, re.M)
    return add

#-- update methods --

# directive insertion doesn't look for context
def append(fn, directive, value, comment=""):
    src = read(fn)
    insert = f"{directive} {value}   {comment}\n"
    insert = prepend_pfx(insert, pfx(src))
    rx_end = rx_end_preconf(fn)
    srcnew = rx_end.sub(insert, src, 1)
    write(fn, srcnew)        # count ↑ =0 would insert before all </VirtualHost> markers

# strip SecRuleRemoveById …? nnnnnnn …?
def remove_remove(fn, directive, value):
    src = read(fn)
    variants = {
        rf"^\s* {directive} \s+ {value} \s* (\#.*)?$": r'',
        rf"^ ( \s*{directive} \s+ (?:\d+\s+)+ ) \b{value}\b ( .* )$": r'\1\2'
    }
    for rx,repl in variants.items():
        if re.search(rx, src, re.X|re.M|re.I):
            src = re.sub(rx, repl, src, 1, re.X|re.M|re.I)
            return write(fn, src)
    print("NOT FOUND / NO CHANGES")

# list of SecOptions to be added/changed
def update_or_add(fn, pairs):
    src = read(fn)
    spc = pfx(src)
    for dir,val in pairs.items():
        # dir=regex
        if type(dir) is re.Pattern:
            if re.search(dir, src):
                src = re.sub(dir, val, src, 1)
            else:
                src = src + val
        # StringDirective
        elif re.search(rf"^[\ \t]*({dir}\b)", src, re.M|re.I):
            src = re.sub(rf"^([\ \t]*)({dir}\b).+\n", f"\\1{dir} {val}\n", src, 1, re.M|re.I)
        else:
            src = src + f"{spc}{dir} {val}\n"
    write(fn, src)