├── dawgmon ├── __init__.py ├── version.py ├── __main__.py ├── commands │ ├── ubuntu.py │ ├── uptime.py │ ├── sysv.py │ ├── env.py │ ├── processes.py │ ├── __init__.py │ ├── block.py │ ├── mount.py │ ├── debian.py │ ├── version.py │ ├── network.py │ ├── files.py │ ├── users.py │ ├── ipc.py │ └── systemd.py ├── local.py ├── utils.py ├── cache.py └── dawgmon.py ├── .gitignore ├── bin └── dawgmon ├── cronjob ├── TODO ├── setup.py ├── LICENSE └── README /dawgmon/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dawgmon/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.1" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | __pycache__ 4 | .*.swo 5 | *.db 6 | -------------------------------------------------------------------------------- /dawgmon/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | if __name__ == "__main__": 4 | from dawgmon.dawgmon import main 5 | main() 6 | -------------------------------------------------------------------------------- /bin/dawgmon: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | PYBIN="/usr/bin/env python3" 3 | DIR=$(dirname "$(readlink -f "$0")") 4 | PYTHONVERSION=`$PYBIN --version | sed 's/Python 3\.\([0-9]*\)\.[0-9]*/\1/'` 5 | if [ $PYTHONVERSION -lt "3" ]; then 6 | echo "Requires at least Python 3.3" 7 | exit 1 8 | fi 9 | export PYTHONPATH=$DIR/../ 10 | $PYBIN -m dawgmon "$@" 11 | -------------------------------------------------------------------------------- /dawgmon/commands/ubuntu.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | class IsRestartRequiredCommand(Command): 4 | name = "needs_restart" 5 | shell = True 6 | command = "/bin/sh -c 'if test -f /var/run/reboot-required.pkgs ; then cat /var/run/reboot-required.pkgs; fi'" 7 | desc = "checks whether a reboot is required (Ubuntu-only)" 8 | 9 | def parse(output): 10 | return output 11 | 12 | def compare(prev, cur): 13 | if len(cur) > 0: 14 | pkgs = cur.splitlines() 15 | pkgs.sort() 16 | return [W("reboot required"), D("reboot required because of packages [%s]" % (",".join(pkgs)))] 17 | return [] 18 | -------------------------------------------------------------------------------- /dawgmon/local.py: -------------------------------------------------------------------------------- 1 | import subprocess, shlex 2 | from dawgmon import commands 3 | 4 | def local_run(dirname, commandlist): 5 | for cmdname in commandlist: 6 | cmd = commands.COMMAND_CACHE[cmdname] 7 | 8 | # shell escape such that we can pass command properly onwards 9 | # to the Popen call 10 | cmd_to_execute = shlex.split(cmd.command) 11 | 12 | p = subprocess.Popen(cmd_to_execute, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 13 | stdout, stderr = p.communicate() 14 | 15 | # XXX we should probably try and get the system encoding for 16 | # this instead of defaulting to UTF-8. 17 | stdout = stdout.decode("utf-8") 18 | stderr = stderr.decode("utf-8") 19 | 20 | yield (cmd.name, "$ %s" % " ".join(cmd_to_execute), p.returncode, stdout, stderr) 21 | -------------------------------------------------------------------------------- /dawgmon/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | 4 | DATE_PARSE_STR = "%Y-%m-%d %H:%M:%S.%f" 5 | 6 | # return a list with unique and sorted set of keys of dictionary d1 and d2 7 | def merge_keys_to_list(d1, d2): 8 | ret = list(set(list(d1.keys()) + list(d2.keys()))) 9 | ret.sort() 10 | return ret 11 | 12 | # if subsecs is True we return mili/micro seconds too otherwise just seconds 13 | def ts_to_str(timestamp, subsecs=True): 14 | return None if not timestamp else timestamp.strftime(DATE_PARSE_STR if subsecs else DATE_PARSE_STR[:-3]) 15 | 16 | # if subsecs is True we convert mili/micro seconds too otherwise the input 17 | # string is assumed to only contain maximum second precision 18 | def str_to_ts(s, subsecs=True): 19 | return None if not s else datetime.strptime(s, DATE_PARSE_STR if subsecs else DATE_PARSE_STR[:-3]) 20 | -------------------------------------------------------------------------------- /cronjob: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # change SENDEMAIL to yes and change rest of the settings to send email with 4 | # the dawgmon analysis results 5 | SENDEMAIL=no 6 | HOST="hostname" 7 | MAILTO="root@hostname" 8 | MAILTONAME="root-user" 9 | MAILFROM=$MAILTO 10 | MAILFROMNAME=$MAILTONAME 11 | MAILBIN=/usr/sbin/ssmtp 12 | 13 | # run dawgmon and collect output to temporary file 14 | BIN=$(dirname "$(readlink -f "$0")")/dawgmon 15 | OUTPUT=$(mktemp) 16 | $BIN -fA > $OUTPUT 17 | 18 | # send out the email if $SENDEMAIL == "yes" only 19 | if [ "$SENDEMAIL" = "yes" ]; then 20 | MAIL=$(mktemp) 21 | printf "To: $MAILTONAME <$MAILTO>\n" >> $MAIL 22 | printf "From: $MAILFROMNAME <$MAILFROM>\n" >> $MAIL 23 | printf "Subject: dawgmon - $HOST\n" >> $MAIL 24 | cat $OUTPUT >> $MAIL 25 | cat $MAIL | $MAILBIN $MAILTO 26 | rm $MAIL 27 | else 28 | cat $OUTPUT 29 | fi 30 | 31 | # remove temporary output file 32 | rm $OUTPUT 33 | -------------------------------------------------------------------------------- /dawgmon/commands/uptime.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | from datetime import datetime 3 | 4 | PARSE_STR = "%Y-%m-%d %H:%M:%S" 5 | 6 | class UptimeCommand(Command): 7 | name = "uptime" 8 | shell = False 9 | command = "uptime -s" 10 | desc = "show uptime and check if reboot happened" 11 | 12 | def parse(output): 13 | if not output: 14 | return None 15 | try: 16 | return datetime.strptime(output.strip(), PARSE_STR) 17 | except ValueError: 18 | return None 19 | 20 | def compare(prev, cur): 21 | scur = cur.strftime(PARSE_STR) 22 | if not prev: 23 | return [D("system has been up since %s" % scur)] 24 | sprev = prev.strftime(PARSE_STR) 25 | if cur != prev: 26 | if cur > prev: 27 | return [W("system rebooted since last check (up since: %s)" % scur)] 28 | else: 29 | return [W("time traveling detected as uptime went from %s to %s" % (sprev, scur))] 30 | else: 31 | return [D("system didn't reboot since last check (up since: %s)" % scur)] 32 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - add crontab analysis with something like this 2 | for user in $(cut -f1 -d: /etc/passwd); do echo $user; crontab -u $user -l; done 3 | this means we need to have root options added to the system or at least 4 | specify which commands in the list need root access specifically 5 | 6 | - need to add support for containers and what not 7 | if we have the root support then systemctl list-machines seems to do the trick 8 | on systemd capable machines 9 | 10 | - SELinux support; it would be nice if we could monitor SELinux changes to things too 11 | 12 | - add OS detection and make commands have different subcommands based on the 13 | type of OS being detected f.e. execute on fbsd/obsd with find/ls commandline options 14 | which are standard on the BSDs and on lnx execute with different ones standard 15 | in the GNU coreutils world 16 | 17 | - add versioning to the cache database to be able to give a warning when cache 18 | is outdated versus the tool and ignore its data instead of throwing random 19 | errors and exceptions 20 | 21 | - look at replacing netstat with ss/sockstat on lnx/freebsd 22 | -------------------------------------------------------------------------------- /dawgmon/commands/sysv.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | class ListSystemVInitJobsCommand(Command): 4 | name = "list_sysvinit_jobs" 5 | shell = False 6 | command = "/usr/sbin/service --status-all" 7 | desc = "analyze changes in available System V init jobs" 8 | 9 | def parse(output): 10 | res = {} 11 | lines = output.splitlines() 12 | for line in lines: 13 | parts = line.split() 14 | res[parts[3]] = parts[1] 15 | return res 16 | 17 | def compare(prev, cur): 18 | anomalies = [] 19 | services = merge_keys_to_list(prev, cur) 20 | for service in services: 21 | if service not in prev: 22 | anomalies.append(C("sysvinit job %s added" % service)) 23 | continue 24 | elif service not in cur: 25 | anomalies.append(C("sysvinit job %s removed" % service)) 26 | continue 27 | p, c = prev[service], cur[service] 28 | if p == c: 29 | continue 30 | if p == "+" and c == "-": 31 | s = "stopped" 32 | elif p =="-" and c == "+": 33 | s = "started" 34 | else: 35 | s = "unknown" 36 | anomalies.append(C("sysvinit job %s %s" % (service, s))) 37 | return anomalies 38 | -------------------------------------------------------------------------------- /dawgmon/commands/env.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | class EnvironmentVariablesCommand(Command): 4 | name = "env" 5 | shell = False 6 | command = "env" 7 | desc = "monitor changes in environment variables" 8 | 9 | def parse(output): 10 | lines = output.splitlines() 11 | if len(lines) == 0: 12 | return {} 13 | ret = {} 14 | for line in lines: 15 | lf = line.find("=") 16 | name = line[:lf].strip() 17 | value = line[lf+1:].strip() 18 | ret[name] = value 19 | return ret 20 | 21 | def compare(prev, cur): 22 | anomalies = [] 23 | envvars = merge_keys_to_list(prev, cur) 24 | for var in envvars: 25 | if var not in prev: 26 | anomalies.append(C("environment variable %s added ('%s')" % (var, cur[var]))) 27 | continue 28 | elif var not in cur: 29 | anomalies.append(C("environment variable %s removed (was: '%s')" % (var, prev[var]))) 30 | continue 31 | elif prev[var] != cur[var]: 32 | anomalies.append(C("environment variable %s changed from '%s' to '%s'" % (var, prev[var], cur[var]))) 33 | anomalies.append(D("environment variable %s=%s" % (var, cur[var]))) 34 | return anomalies 35 | -------------------------------------------------------------------------------- /dawgmon/commands/processes.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | class CheckProcessessCommand(Command): 4 | name = "list_processes" 5 | shell = False 6 | command = "ps aux" 7 | desc = "monitors changes in the running processes (mostly for debugging)" 8 | 9 | def parse(output): 10 | res = {} 11 | lines = output.splitlines() 12 | # ignore the first header line of the output 13 | for line in lines[1:]: 14 | parts = line.split() 15 | user, pid = parts[0:2] 16 | pid = int(pid) 17 | cmd = parts[10] 18 | 19 | # start will be HH:MM if started same day, MmmDD if different day 20 | # but same year and simply year if started a different year 21 | start = parts[8] 22 | res[pid] = (cmd, user, start) 23 | return res 24 | 25 | def compare(prev, cur): 26 | anomalies = [] 27 | processes = merge_keys_to_list(prev, cur) 28 | for process in processes: 29 | if process not in prev: 30 | anomalies.append(D("process %i (%s) started" % (process, cur[process][0]))) 31 | continue 32 | elif process not in cur: 33 | anomalies.append(D("process %i (%s) stopped" % (process, prev[process][0]))) 34 | continue 35 | p, c = prev[process], cur[process] 36 | if p[0] != c[0]: 37 | anomalies.append(D("process %i changed from %s to %s" % (process, p[0], c[0]))) 38 | elif p[1] != c[1]: 39 | anomalies.append(D("owner of process %i changed from %s to %s" % (process, p[1], c[1]))) 40 | return anomalies 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from dawgmon.version import VERSION 3 | 4 | with open("README", "r") as fh: 5 | long_description = fh.read() 6 | 7 | setuptools.setup( 8 | name="dawgmon", 9 | version=VERSION, 10 | author="Anvil Ventures", 11 | author_email="info@anvilventures.com", 12 | description="Monitor operating system changes and analyze " 13 | "introduced attack surface when installing software", 14 | long_description=long_description, 15 | long_description_content_type="text/plain", 16 | url="https://github.com/anvilventures/dawgmon", 17 | packages=setuptools.find_packages(), 18 | classifiers=[ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: BSD License", 21 | "Operating System :: OS Independent", 22 | "Development Status :: 5 - Production/Stable", 23 | "Operating System :: POSIX :: Linux", 24 | "Operating System :: POSIX :: Other", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Topic :: System :: Monitoring", 27 | "Topic :: System :: Systems Administration", 28 | "Programming Language :: Python :: 3.6", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.6", 32 | ], 33 | python_requires=">=3.6", 34 | entry_points = { 35 | "console_scripts": ["dawgmon=dawgmon.dawgmon:main"], 36 | } 37 | ) 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Anvil Ventures Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 1. Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 3. Neither the name of the University nor the names of its contributors 13 | may be used to endorse or promote products derived from this software 14 | without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 22 | OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 23 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 24 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 25 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 26 | SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /dawgmon/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # helper routines to turn messages into the right 2 | # types for displayment later 3 | WARNING, DEBUG, CHANGE = 0x1, 0x2, 0x3 4 | from datetime import datetime 5 | def W(s): 6 | return (WARNING, s, datetime.utcnow()) 7 | def D(s): 8 | return (DEBUG, s, datetime.utcnow()) 9 | def C(s): 10 | return (CHANGE, s, datetime.utcnow()) 11 | 12 | from ..utils import merge_keys_to_list 13 | 14 | # base Command class 15 | class Command: 16 | desc = "" 17 | @classmethod 18 | def parse(cls, output): 19 | raise Exception("not implemented for %s" % str(cls)) 20 | @classmethod 21 | def compare(cls, prev, cur): 22 | raise Exception("not implemented for %s" % str(cls)) 23 | 24 | 25 | from .block import * 26 | from .debian import * 27 | from .env import * 28 | from .files import * 29 | from .ipc import * 30 | from .mount import * 31 | from .network import * 32 | from .processes import * 33 | from .systemd import * 34 | from .sysv import * 35 | from .ubuntu import * 36 | from .uptime import * 37 | from .users import * 38 | from .version import * 39 | # commands will be executed in the order they appear in this list 40 | COMMANDS = [ 41 | files.CheckBootDirectoryCommand, 42 | files.CheckEtcDirectoryCommand, 43 | files.CheckForPipesCommand, 44 | files.FindSuidBinariesCommand, 45 | env.EnvironmentVariablesCommand, 46 | ipc.ListMessageQueuesCommand, 47 | ipc.ListSemaphoreArraysCommand, 48 | ipc.ListSharedMemorySegmentsCommand, 49 | ipc.ListListeningUNIXSocketsCommand, 50 | mount.MountpointsCommand, 51 | processes.CheckProcessessCommand, 52 | systemd.ListSystemDPropertiesCommand, 53 | systemd.ListSystemDSocketsCommand, 54 | systemd.ListSystemDTimersCommand, 55 | systemd.ListSystemDUnitFilesCommand, 56 | systemd.ListSystemDUnitsCommand, 57 | sysv.ListSystemVInitJobsCommand, 58 | ubuntu.IsRestartRequiredCommand, 59 | debian.ListInstalledPackagesCommand, 60 | uptime.UptimeCommand, 61 | users.CheckGroupsCommand, 62 | users.CheckUsersCommand, 63 | version.KernelVersionCommand, 64 | version.LSBVersionCommand, 65 | network.ListListeningTCPUDPPortsCommand, 66 | network.ListNetworkInterfacesCommand, 67 | block.ListBlockDevicesCommand 68 | ] 69 | 70 | # built up mapping from command names to command classes 71 | COMMAND_CACHE = {} 72 | for cmd in COMMANDS: 73 | COMMAND_CACHE[cmd.name] = cmd 74 | -------------------------------------------------------------------------------- /dawgmon/commands/block.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | class ListBlockDevicesCommand(Command): 4 | name = "list_blkdev" 5 | shell = False 6 | command = "/bin/lsblk -la" 7 | desc = "analyze changes in available block devices" 8 | 9 | def parse(output): 10 | lines = output.splitlines() 11 | if len(lines) == 0: 12 | return {} 13 | ret = {} 14 | header = lines[0] 15 | lines = lines[1:] 16 | for line in lines: 17 | e = [s.strip() for s in line.split()] 18 | le = len(e) 19 | if le == 5: 20 | name, maj_min, rm, ro, blktype = e[0:5] 21 | size = "0" # needs to be string to be in line with the size strings from the tool output 22 | elif le == 6 or le == 7: 23 | name, maj_min, rm, size, ro, blktype = e[0:6] 24 | mount = e[6] if len(e) == 7 else None 25 | ret[e[0]] = (maj_min, int(rm), size, int(ro), blktype, mount) 26 | return ret 27 | 28 | def compare(prev, cur): 29 | anomalies = [] 30 | blocks = merge_keys_to_list(prev, cur) 31 | for blk in blocks: 32 | if blk not in prev: 33 | maj_min, rm, size, ro, blktype, mount = cur[blk] 34 | anomalies.append(C("block device %s added (type=%s, size=%s, mount=%s, maj:min=%s)" % (blk, blktype, size, mount, maj_min))) 35 | continue 36 | elif blk not in cur: 37 | maj_min, rm, size, ro, blktype, mount = prev[blk] 38 | anomalies.append(C("block device %s removed (type=%s, size=%s, mount=%s, maj:min=%s)" % (blk, blktype, size, mount, maj_min))) 39 | continue 40 | p, c = prev[blk], cur[blk] 41 | if p[0] != c[0]: 42 | anomalies.append(C("block device %s had maj:min change from %s to %s" % (blk, p[0], c[0]))) 43 | if p[1] != c[1]: 44 | anomalies.append(C("block device %s had RM change from %s to %s" % (blk, p[1], c[1]))) 45 | if p[2] != c[2]: 46 | anomalies.append(C("block device %s had its size changed from %s to %s" % (blk, p[2], c[2]))) 47 | if p[3] != c[3]: 48 | anomalies.append(C("block device %s had RO change from %s to %s" % (blk, p[3], c[3]))) 49 | if p[4] != c[4]: 50 | anomalies.append(C("block device %s had its type changed from %s to %s" % (blk, p[4], c[4]))) 51 | if p[5] != c[5]: 52 | anomalies.append(C("block device %s had its mount changed from %s to %s" % (blk, p[5], c[5]))) 53 | maj_min, rm, size, ro, blktype, mount = cur[blk] 54 | anomalies.append(D("block device %s (type=%s, size=%s, mount=%s, maj:min=%s)" % (blk, blktype, size, mount, maj_min))) 55 | return anomalies 56 | -------------------------------------------------------------------------------- /dawgmon/commands/mount.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | def change_attr_list_to_dict(attrs): 4 | ret = {} 5 | for attr in attrs: 6 | lf = attr.find("=") 7 | val = attr[lf+1:] if lf != -1 else None 8 | name = attr[0:lf] if lf != -1 else attr 9 | ret[name] = val 10 | return ret 11 | 12 | class MountpointsCommand(Command): 13 | name = "list_mount" 14 | shell = False 15 | command = "/bin/mount" 16 | desc = "analyze changes in file system mounts" 17 | 18 | def parse(output): 19 | lines = output.splitlines() 20 | if len(lines) == 0: 21 | return {} 22 | ret = {} 23 | for line in lines: 24 | lf = line.find("on") 25 | device = line[:lf].strip() 26 | line = line[lf+3:] 27 | lf = line.find("type") 28 | point = line[:lf].strip() 29 | line = line[lf+5:] 30 | lf = line.find("(") 31 | mtype = line[:lf].strip() 32 | # strip off final ')' 33 | attrs = [a.strip() for a in line[lf+1:-1].split(",")] 34 | ret[point] = (device, mtype, attrs) 35 | return ret 36 | 37 | def compare(prev, cur): 38 | anomalies = [] 39 | mounts = merge_keys_to_list(prev, cur) 40 | for mount in mounts: 41 | if mount not in prev: 42 | c = cur[mount] 43 | anomalies.append(C("added mount %s of type %s for device %s (%s)" % (mount, c[1], c[0], ",".join(c[2])))) 44 | continue 45 | elif mount not in cur: 46 | p = prev[mount] 47 | anomalies.append(C("removed mount %s of type %s for device %s (%s)" % (mount, p[1], p[0], ",".join(p[2])))) 48 | continue 49 | p, c = prev[mount], cur[mount] 50 | if p[0] != c[0]: 51 | anomalies.append(C("mount %s changed device from %s to %s" % (mount, p[0], c[0]))) 52 | if p[1] != c[1]: 53 | anomalies.append(C("mount %s changed type from %s to %s" % (mount, p[1], c[1]))) 54 | pattr, cattr = p[2], c[2] 55 | pattr = change_attr_list_to_dict(pattr) 56 | cattr = change_attr_list_to_dict(cattr) 57 | attrs = merge_keys_to_list(pattr, cattr) 58 | for attr in attrs: 59 | if attr not in pattr: 60 | sval = " with value %s" % cattr[attr] if cattr[attr] else "" 61 | anomalies.append(C("attribute %s got added to mount %s%s" % (attr, mount, sval))) 62 | continue 63 | elif attr not in cattr: 64 | sval = " with value %s" % pattr[attr] if pattr[attr] else "" 65 | anomalies.append(C("attribute %s was removed from mount %s%s" % (attr, mount, sval))) 66 | continue 67 | pa, ca = pattr[attr], cattr[attr] 68 | if pa != ca: 69 | pa = pa if pa else "" 70 | ca = ca if ca else "" 71 | anomalies.append(C("attribute %s for mount %s changed from %s to %s" % (attr, mount, pa, ca))) 72 | anomalies.append(D("mount %s of type %s for device %s (%s)" % (mount, c[1], c[0], ",".join(c[2])))) 73 | return anomalies 74 | -------------------------------------------------------------------------------- /dawgmon/cache.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | from dawgmon.utils import ts_to_str, str_to_ts 5 | 6 | class CacheException(Exception): 7 | pass 8 | 9 | class Cache: 10 | def __init__(self, fn): 11 | self.fn = fn 12 | self.data = {} 13 | 14 | def load(self, create=True): 15 | try: 16 | with open(self.fn, "r") as fd: 17 | self.data = json.load(fd) 18 | except FileNotFoundError: 19 | # try and create and empty cache 20 | self.data = {} 21 | self.save() 22 | return 23 | 24 | def save(self): 25 | with open(self.fn, "w+") as fd: 26 | json.dump(self.data, fd) 27 | 28 | def purge(self, no): 29 | if not no or type(no) != int or no < 0: 30 | return False 31 | for hostname in self.data: 32 | self.data[hostname] = self.data[hostname][-no:] 33 | return True 34 | 35 | def get_hostnames(self): 36 | hostnames = list(self.data.keys()) 37 | hostnames.sort() 38 | return hostnames 39 | 40 | def get_entries(self, hostname=None): 41 | if hostname and hostname not in self.get_hostnames(): 42 | return [] 43 | res = [] 44 | for entry_hostname in self.data: 45 | if hostname and hostname != entry_hostname: 46 | continue 47 | entries = self.data[entry_hostname] 48 | count = 0 49 | for entry in entries: 50 | res.append({"hostname":entry_hostname, "timestamp":entry["timestamp"], "id":count}) 51 | count = count + 1 52 | return res 53 | 54 | def get_entry(self, entry_id, hostname="localhost"): 55 | entries = self.get_entries(hostname) 56 | if len(entries) == 0: 57 | return None 58 | if entry_id == -1: 59 | # default to the last entry 60 | entry_id = entries[-1]["id"] 61 | elif entry_id >= len(self.data[hostname]): 62 | return None 63 | return self.data[hostname][entry_id]["data"] 64 | 65 | def get_entry_timestamp(self, entry_id, hostname="localhost"): 66 | entries = self.get_entries(hostname) 67 | if len(entries) == 0: 68 | return None 69 | if entry_id == -1: 70 | # default to the last entry 71 | entry_id = entries[-1]["id"] 72 | elif entry_id >= len(self.data[hostname]): 73 | return None 74 | 75 | # fall back automatically on timestamps that do not record 76 | # subsecond intervals such as the timestamps that are in old 77 | # caches 78 | ts = self.data[hostname][entry_id]["timestamp"] 79 | try: 80 | return str_to_ts(ts) 81 | except ValueError: 82 | return str_to_ts(ts, False) 83 | 84 | def get_last_entry(self, hostname="localhost"): 85 | return self.get_entry(-1, hostname) 86 | 87 | def get_last_entry_timestamp(self, hostname="localhost"): 88 | return self.get_entry_timestamp(-1, hostname) 89 | 90 | def add_entry(self, data, hostname="localhost", timestamp=None): 91 | self.data.setdefault(hostname, []) 92 | if not timestamp: 93 | timestamp = datetime.utcnow() 94 | tsnow = ts_to_str(timestamp) 95 | self.data[hostname].append({"timestamp":tsnow, "data":data}) 96 | -------------------------------------------------------------------------------- /dawgmon/commands/debian.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | 4 | def convert_code_to_string(table, c, desc=False): 5 | if not c or type(c) != str or len(c) != 1: 6 | raise ValueError("invalid argument supplied") 7 | bad = " (bad)" if c.isupper() else "" 8 | c = c.lower() 9 | s = table[c][0 if not desc else 1] if c in table else "unknown (%c)" % c 10 | return "%s%s" % (s, bad) 11 | 12 | def error_string(c, desc=False): 13 | errors = {"r":("Reinst-required", "reinstall required")} 14 | return convert_code_to_string(errors, c, desc) 15 | 16 | def desired_string(c, desc=False): 17 | desired = { 18 | "u":("Unknown", "unknown"), 19 | "i":("Install", "to be installed"), 20 | "r":("Remove", "to be removed from package list"), 21 | "p":("Purge", "to be purged from system"), 22 | "h":("Hold", "to be held") 23 | } 24 | return convert_code_to_string(desired, c, desc) 25 | 26 | def status_string(c, desc=False): 27 | statuses = { 28 | "n":("Not", "unknown"), 29 | "i":("Inst", "is installed"), 30 | "c":("Conf-files", "has configuration files"), 31 | "u":("Unpacked", "is unpacked"), 32 | "f":("halF-conf", "is half configured"), 33 | "H":("Half-inst", "is half installed"), 34 | "w":("trig-aWait", "is awaiting a trigger"), 35 | "t":("trig-pend", "has a trigger pending") 36 | } 37 | return convert_code_to_string(statuses, c, desc) 38 | 39 | class ListInstalledPackagesCommand(Command): 40 | name = "list_packages" 41 | shell = False 42 | command = "/usr/bin/dpkg --list" 43 | desc = "analyze changes in installed Debian packages" 44 | 45 | def parse(output): 46 | res = {} 47 | lines = output.splitlines() 48 | header_done = False 49 | for line in lines: 50 | parts = line.split() 51 | p0 = parts[0] 52 | if len(p0) >= 3 and p0[0:3] == "+++": 53 | header_done = True 54 | continue 55 | if not header_done: 56 | continue 57 | version = parts[2] 58 | status = parts[0] 59 | res[parts[1]] = (version, status) 60 | return res 61 | 62 | def compare(prev, cur): 63 | anomalies = [] 64 | packages = merge_keys_to_list(prev, cur) 65 | for package in packages: 66 | if package not in prev: 67 | c = cur[package] 68 | cdesired, cstatus = c[1][0:2] 69 | anomalies.append(C("package %s is now added (desire is '%s', status is '%s')" % (package, desired_string(cdesired, True), status_string(cstatus, True)))) 70 | continue 71 | elif package not in cur: 72 | p = prev[package] 73 | pdesired, pstatus = p[1][0:2] 74 | anomalies.append(C("package %s is now removed (status used to be '%s')" % (package, status_string(pstatus, True)))) 75 | continue 76 | p, c = prev[package], cur[package] 77 | if p[0] != c[0]: 78 | anomalies.append(C("package %s version changed from %s to %s" % (package, p[0], c[0]))) 79 | pdesired, pstatus = p[1][0:2] 80 | cdesired, cstatus = c[1][0:2] 81 | if pstatus != cstatus: 82 | anomalies.append(D("package %s status changed from '%s' to '%s'" % (package, status_string(pstatus, True), status_string(cstatus, True)))) 83 | if pdesired == cdesired: 84 | continue 85 | anomalies.append(C("package %s is %s" % (package, desired_string(cdesired, True)))) 86 | return anomalies 87 | -------------------------------------------------------------------------------- /dawgmon/commands/version.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | class KernelVersionCommand(Command): 4 | name = "kernel_version" 5 | shell = True 6 | command = "/bin/sh -c 'printf \"`uname -a`\\n`uname -v`\"'" 7 | desc = "analyze changes in kernel version" 8 | 9 | def parse(output): 10 | lines = output.splitlines() 11 | if len(lines) != 2: 12 | return None 13 | kernel_version = lines[1] 14 | line = lines[0].split(kernel_version)[0].split() 15 | kernel_name, hostname, kernel_release = line 16 | return (kernel_name, hostname, kernel_release, kernel_version) 17 | 18 | def compare(prev, cur): 19 | ret = [] 20 | if not prev: 21 | prev = cur 22 | 23 | # a hostname change is something for which we want to see a warning 24 | if prev[1] != cur[1]: 25 | ret.append(W("hostname changed from %s to %s" % (prev[1], cur[1]))) 26 | else: 27 | ret.append(D("kernel version check (hostname) yields %s" % cur[1])) 28 | 29 | # count changes and if we found anything which changed in the 30 | # kernel's name, version or release information the kernel got 31 | # updated so output a warning too then. 32 | c = 0 33 | if prev[0] != cur[0]: 34 | ret.append(C("kernel name changed from %s to %s" % (prev[0], cur[0]))) 35 | c = c + 1 36 | else: 37 | ret.append(D("kernel version check (kernel name) yields %s" % cur[0])) 38 | if prev[2] != cur[2]: 39 | ret.append(C("kernel release changed from %s to %s" % (prev[2], cur[2]))) 40 | c = c + 1 41 | else: 42 | ret.append(D("kernel version check (kernel release) yields %s" % cur[2])) 43 | if prev[3] != cur[3]: 44 | ret.append(C("kernel version changed from %s to %s" % (prev[3], cur[3]))) 45 | c = c + 1 46 | else: 47 | ret.append(D("kernel version check (kernel version) yields %s" % cur[3])) 48 | 49 | # if we see a count of > 0 it means something in the kernel has 50 | # changed so output a warning 51 | if c > 0: 52 | ret.append(W("kernel seems to have changed from %s to %s" % (" ".join(prev), " ".join(cur)))) 53 | return ret 54 | 55 | class LSBVersionCommand(Command): 56 | name = "lsb_version" 57 | shell = False 58 | command = "/usr/bin/lsb_release -idcr" 59 | desc = "analyze changes in Linux Standard Base release settings" 60 | 61 | def parse(output): 62 | lines = output.splitlines() 63 | if len(lines) != 4: 64 | return {} 65 | ret = {} 66 | for line in lines: 67 | lf = line.strip().find(":") 68 | prop = line[0:lf].strip() 69 | val = line[lf+1:].strip() 70 | ret[prop] = val 71 | return ret 72 | 73 | def compare(prev, cur): 74 | anomalies = [] 75 | entries = merge_keys_to_list(prev, cur) 76 | for entry in entries: 77 | p = prev[entry] if entry in prev else "" 78 | c = cur[entry] if entry in cur else "" 79 | if entry not in ["Description", "Distributor ID", "Codename", "Release"]: 80 | anomalies.append(W("unknown entry '%s' returned by lsb_release prev: '%s', cur: '%s'" % (entry, p, c))) 81 | elif p == "": 82 | anomalies.append(C("LSB '%s' added with value '%s'" % (entry, c))) 83 | elif c == "": 84 | anomalies.append(W("LSB '%s' removed somehow (had value '%s')" % (entry, p))) 85 | elif p != c: 86 | anomalies.append(C("LSB %s changed from '%s' to '%s'" % (entry, p, c))) 87 | else: 88 | anomalies.append(D("LSB %s = %s" % (entry, c))) 89 | return anomalies 90 | -------------------------------------------------------------------------------- /dawgmon/commands/network.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | class ListListeningTCPUDPPortsCommand(Command): 4 | name = "list_tcpudp_ports" 5 | shell = False 6 | command = "netstat --tcp --udp -ln" 7 | desc = "list changes in listening TCP/UDP ports for both IPv4/IPv6" 8 | 9 | def parse(output): 10 | res = {} 11 | output = output.splitlines()[2:] 12 | for line in output: 13 | proto, addr = line.split()[0:4:3] 14 | port = int(addr[addr.rfind(":")+1:]) 15 | pe = res.setdefault(port, []) 16 | pe.append(proto) 17 | for port in res: 18 | res[port] = list(set(res[port])) 19 | res[port].sort() 20 | return res 21 | 22 | def compare(prev, cur): 23 | anomalies = [] 24 | ports = merge_keys_to_list(prev, cur) 25 | for port in ports: 26 | prev_types = "/".join(prev[port]) if port in prev else None 27 | cur_types = "/".join(cur[port]) if port in cur else None 28 | if port not in cur: 29 | anomalies.append(C("port %i %s closed" % (port, prev_types))) 30 | continue 31 | elif port not in prev: 32 | anomalies.append(C("port %i %s opened" % (port, cur_types))) 33 | continue 34 | if prev_types != cur_types: 35 | anomalies.append(C("port %i is open and changed from %s to %s" % (port, prev_types, cur_types))) 36 | anomalies.append(D("port %i %s is listening" % (port, cur_types))) 37 | return anomalies 38 | 39 | class ListNetworkInterfacesCommand(Command): 40 | name = "list_ifaces" 41 | shell = False 42 | command = "/bin/ip addr" 43 | desc = "analyze changes in network interfaces" 44 | 45 | def parse(output): 46 | res = {} 47 | linecount = 0 48 | lines = output.splitlines() 49 | if len(lines) == 0: 50 | return res 51 | # get entry ranges by filtering on the 1:, 2:, 3: output please 52 | # note that upon inserting and removal of f.e. a USB interface 53 | # the numbers will still increase so we just need to keep 54 | # counting with the max count never being possible more than 55 | # the total amount of lines obviously. so sometimes upon 56 | # insertion and removal a couple of times one can end up with 57 | # entry numbers such as 1:, 2:, 5: etc. 58 | entries = [] 59 | for line in lines: 60 | for i in range(1, len(lines)): 61 | s = "%i:" % (i) 62 | if len(line) >= len(s) and line[:len(s)] == s: 63 | entries.append(linecount) 64 | i = i + 1 65 | break 66 | linecount = linecount + 1 67 | le, ll = len(entries), len(lines) 68 | for i, entry in enumerate(entries): 69 | entry_lines = lines[entry:entries[i+1] if i < le-1 else ll] 70 | s = "%i: " % (i) 71 | line0 = entry_lines[0][len(s):] 72 | iface, *rest = line0.split(":") 73 | rest = "".join(rest) 74 | lf = rest.find("state ") 75 | state, *rest = rest[lf+6:].split() 76 | # now parse inet and inet6 addrs 77 | addrs = [] 78 | for entry_line in entry_lines: 79 | es = entry_line.strip() 80 | if es.startswith("inet"): 81 | inettype, addr, *rest = es.split() 82 | addrs.append((inettype, addr)) 83 | # dict keys on iface with the stat and list of addresses 84 | res[iface] = (state, set(addrs)) 85 | return res 86 | 87 | def compare(prev, cur): 88 | anomalies = [] 89 | ifaces = merge_keys_to_list(prev, cur) 90 | for iface in ifaces: 91 | if iface not in prev: 92 | addrlist = ",".join(["%s %s" % (x[0], x[1]) for x in cur[iface][1]]) 93 | anomalies.append(C("network interface %s added with state %s [%s]" % (iface, cur[iface][0], addrlist))) 94 | continue 95 | elif iface not in cur: 96 | addrlist = ",".join(["%s %s" % (x[0], x[1]) for x in prev[iface][1]]) 97 | anomalies.append(C("network interface %s with state %s removed [%s]" % (iface, prev[iface][0], addrlist))) 98 | continue 99 | p, c = prev[iface], cur[iface] 100 | if p[0] != c[0]: 101 | anomalies.append(C("network interface %s state changed from %s to %s" % (iface, p[0], c[0]))) 102 | paddr, caddr = p[1], c[1] 103 | diff = paddr ^ caddr 104 | for d in diff: 105 | if d not in paddr: 106 | anomalies.append(C("network interface %s got a new address %s %s" % (iface, d[0], d[1]))) 107 | elif d not in caddr: 108 | anomalies.append(C("network interface %s had address %s %s removed" % (iface, d[0], d[1]))) 109 | if len(diff) == 0: 110 | anomalies.append(D("network interface %s with state %s (no change in addresses detected)" % (iface, c[0]))) 111 | return anomalies 112 | 113 | -------------------------------------------------------------------------------- /dawgmon/commands/files.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | from datetime import datetime 4 | 5 | DATE_PARSE_STR = "%Y-%m-%d %H:%M:%S.%f %z" 6 | 7 | # XXX we got probably improve this matching a lot if we also start taking the 8 | # inodes into account but we don't right now and just simply do a 'visual' 9 | # compare which should be sufficient in most cases; we're basically automating 10 | # what a system administrator might do by hand when comparing directory 11 | # listings via something like ls -lha etc. 12 | 13 | class CheckFilesInDirectoryCommand(Command): 14 | shell = True 15 | needs_subclass = True 16 | 17 | # command option --full-time is GNU file utils specific 18 | # -b is for escaping characters in the filename such as spaces and what not more 19 | # two arguments should be start directory and file type (pipe, symlink, regular file etc) 20 | command = "find %s -xdev -ignore_readdir_race -type %s -exec ls --full-time -lba \{\} \;" 21 | 22 | def parse(output): 23 | res = {} 24 | lines = output.splitlines() 25 | for line in lines: 26 | line = bytes(line, "utf-8") 27 | 28 | # hack but with the escape (-b) option this should only trigger 29 | # when it"s an actual symlink so we simply look for " -> " which 30 | # can only be a part of the symlink output because if it would be 31 | # a part of the filename it would be escaped to "\ ->\ ". 32 | search = b" -> " 33 | lf = line.find(search) 34 | issymlink = False 35 | if lf != -1: 36 | symlink = line[lf+4:] 37 | line = line[:lf] 38 | else: 39 | symlink = None 40 | 41 | parts = line.split(b" ", 8) 42 | lp = len(parts) 43 | fn = parts[8].decode("unicode-escape") 44 | symlink = bytes(symlink).decode("unicode-escape") if symlink else None 45 | perm = parts[0] 46 | user, group, size = parts[2:5] 47 | date, ts, tz = parts[5:8] 48 | size = int(size) 49 | # timestamp seems to be given in nano-seconds with Python only 50 | # supporting up to micro-seconds so we 'divide' by 1000 by simply 51 | # stripping off the last 3 bytes 52 | ts = ts[:-3] 53 | dt = datetime.strptime("%s %s %s" % (date.decode("utf-8"), ts.decode("utf-8"), tz.decode("utf-8")), DATE_PARSE_STR) 54 | res[fn] = (user.decode("utf-8"), group.decode("utf-8"), size, dt, perm.decode("utf-8"), symlink) 55 | return res 56 | 57 | def compare(prev, cur, desc="file"): 58 | # the description parameter is a somewhat dirty hack to make 59 | # this more descriptive by changing the language for all the 60 | # files to state for example suid binary if the caller passed 61 | # that in here. 62 | anomalies = [] 63 | fns = merge_keys_to_list(prev, cur) 64 | for fn in fns: 65 | if fn not in cur: 66 | p = prev[fn] 67 | anomalies.append(C("%s %s got unlinked (owner=%s, group=%s, perm=%s, size=%i)" % (desc, fn, p[0], p[1], p[4], p[2]))) 68 | continue 69 | elif fn not in prev: 70 | c = cur[fn] 71 | anomalies.append(C("%s %s got created (owner=%s, group=%s, perm=%s, size=%i)" % (desc, fn, c[0], c[1], c[4], c[2]))) 72 | continue 73 | p, c = prev[fn], cur[fn] 74 | if p[0] != c[0]: 75 | anomalies.append(C("owner of %s %s changed from %s to %s" % (desc, fn, p[0], c[0]))) 76 | if p[1] != c[1]: 77 | anomalies.append(C("group of %s %s changed from %s to %s" % (desc, fn, p[1], c[1]))) 78 | if p[2] != c[2]: 79 | anomalies.append(C("size of %s %s changed from %i to %i" % (desc, fn, p[2], c[2]))) 80 | if p[3] != c[3]: 81 | cs = c[3].strftime(DATE_PARSE_STR) 82 | anomalies.append(C("%s %s got modified on %s" % (desc, fn, cs))) 83 | if p[3] > c[3]: 84 | ps = p[3].strftime(DATE_PARSE_STR) 85 | anomalies.append(W("time traveling detected as %s %s got modified previously at %s and now at %s" % (desc, fn, ps, cs))) 86 | if p[4] != c[4]: 87 | anomalies.append(C("permissions for %s %s changed from %s to %s" % (desc, fn, p[4], c[4]))) 88 | if p[5] != c[5]: 89 | if p[5] is None: 90 | anomalies.append(C("%s %s changed into a symlink pointing to %s" % (desc, fn, c[5]))) 91 | elif c[5] is None: 92 | anomalies.append(C("%s %s used to be a symlnk pointed to %s but is a regular file now" % (desc, fn, p[5]))) 93 | else: 94 | anomalies.append(C("%s %s symlink changed pointing from %s to %s" % (desc, fn, p[5], c[5]))) 95 | return anomalies 96 | 97 | class CheckEtcDirectoryCommand(CheckFilesInDirectoryCommand): 98 | name = "check_etc" 99 | desc = "analyzes /etc directory" 100 | command = "find /etc -xdev -ignore_readdir_race \( -type f -o -type l \) -exec ls --full-time -lba \{\} \;" 101 | 102 | class CheckBootDirectoryCommand(CheckFilesInDirectoryCommand): 103 | name = "check_boot" 104 | directory = "/boot" 105 | desc = "analyzes /boot directory" 106 | command = CheckFilesInDirectoryCommand.command % (directory, "f") 107 | 108 | class CheckForPipesCommand(CheckFilesInDirectoryCommand): 109 | name = "list_pipes" 110 | directory = "/" 111 | desc = "lists named pipes" 112 | command = CheckFilesInDirectoryCommand.command % (directory, "p") 113 | 114 | def compare(prev, cur): 115 | return CheckFilesInDirectoryCommand.compare(prev, cur, "pipe") 116 | 117 | class FindSuidBinariesCommand(CheckFilesInDirectoryCommand): 118 | name = "list_suids" 119 | shell = True 120 | desc = "lists setuid/setgid executables" 121 | command = "find / -xdev -ignore_readdir_race -type f \( -perm -4000 -o -perm -2000 \) -exec ls --full-time -lba \{\} \;" 122 | 123 | def compare(prev, cur): 124 | return CheckFilesInDirectoryCommand.compare(prev, cur, "suid binary") 125 | -------------------------------------------------------------------------------- /dawgmon/commands/users.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | # pw entry set to "x" means password is stored in /etc/shadow or /etc/gshadow 4 | # file # and a completely empty entry there means possibly passwordless login 5 | # for either the user or the group in /etc/passwd or /etc/group respectively. 6 | def is_empty(pw): 7 | return not pw or len(pw) == 0 8 | def is_gshadow(pw): 9 | return pw and pw == "x" 10 | def anonymize_pw(pw): 11 | # the two utility functions above will also work if we 12 | # replace the hash with an anonymous string and the results 13 | # for all the checks will be the same here 14 | if is_empty(pw) or is_gshadow(pw): 15 | return pw 16 | return "" 17 | 18 | class CheckGroupsCommand(Command): 19 | name = "check_groups" 20 | shell = False 21 | command = "cat /etc/group" 22 | desc = "analyze UNIX group changes" 23 | 24 | def parse(data): 25 | data = data.splitlines() 26 | res = {} 27 | for line in data: 28 | parts = line.split(":") 29 | users = parts[3].split(",") 30 | if len(users[0]) == 0: 31 | users = [] 32 | pwhash_entry = anonymize_pw(parts[1]) 33 | res[parts[0]] = (int(parts[2]), users, pwhash_entry) 34 | return res 35 | 36 | def compare(prev, cur): 37 | anomalies = [] 38 | groups = merge_keys_to_list(prev, cur) 39 | shadow_groups = [] 40 | for group in groups: 41 | if group not in prev: 42 | anomalies.append(C("group %s added" % group)) 43 | if cur[group][2]: 44 | shadow_groups.append(group) 45 | continue 46 | elif group not in cur: 47 | anomalies.append(C("group %s removed" % group)) 48 | continue 49 | prev_gid, cur_gid = prev[group][0], cur[group][0] 50 | prev_pw, cur_pw = prev[group][2], cur[group][2] 51 | prev_users, cur_users = prev[group][1], cur[group][1] 52 | if prev_gid != cur_gid: 53 | anomalies.append(C("gid of group %s changed from %i to %i" % (group, prev_gid, cur_gid))) 54 | if is_gshadow(prev_pw) != is_gshadow(cur_pw): 55 | from_file = "gshadow" if is_gshadow(prev_pw) else "group" 56 | to_file = "gshadow" if is_gshadow(cur_pw) else "group" 57 | anomalies.append(C("password for group %s moved from /etc/%s to /etc/%s" % (group, from_file, to_file))) 58 | if is_gshadow(cur_pw) or is_empty(cur_pw): 59 | shadow_groups.append(group) 60 | if is_empty(cur_pw): 61 | anomalies.append(W("password for group %s set to empty (might allow group login)" % (group))) 62 | 63 | all_users = list(set(prev_users + cur_users)) 64 | for user in all_users: 65 | if user not in prev_users: 66 | anomalies.append(C("user %s added to group %s" % (user, group))) 67 | elif user not in cur_users: 68 | anomalies.append(C("user %s removed from group %s" % (user, group))) 69 | in_group = list(set(cur.keys()) ^ set(shadow_groups)) 70 | l = len(in_group) 71 | if l == 0: 72 | anomalies.append(D("all groups have entries for passwords in /etc/gshadow as they should")) 73 | return anomalies 74 | anomalies.append(W("%i password%s for groups not in /etc/gshadow but /etc/group [%s]" % (l, "s" if l != 1 else "", ",".join(in_group)))) 75 | return anomalies 76 | 77 | class CheckUsersCommand(Command): 78 | name = "check_users" 79 | shell = False 80 | command = "cat /etc/passwd" 81 | desc = "analyze UNIX user changes" 82 | 83 | def parse(data): 84 | data = data.splitlines() 85 | res = {} 86 | for line in data: 87 | parts = line.split(":") 88 | login = parts[0] 89 | uid, gid = int(parts[2]), int(parts[3]) 90 | homedir, shell = parts[5].strip(), parts[6].strip() 91 | pwhash_entry = anonymize_pw(parts[1]) 92 | res[login] = (uid, gid, homedir, shell, pwhash_entry) 93 | return res 94 | 95 | def compare(prev, cur): 96 | anomalies = [] 97 | users = merge_keys_to_list(prev, cur) 98 | shadow_users = [] 99 | for user in users: 100 | if user not in prev: 101 | anomalies.append(C("user %s added" % user)) 102 | if cur[user][4]: 103 | shadow_users.append(user) 104 | continue 105 | elif user not in cur: 106 | anomalies.append(C("user %s removed" % user)) 107 | continue 108 | p, c = prev[user], cur[user] 109 | if p[0] != c[0]: 110 | anomalies.append(C("uid for user %s changed from %i to %i" % (user, p[0], c[0]))) 111 | if p[1] != c[1]: 112 | anomalies.append(C("gid for user %s changed from %i to %i" % (user, p[1], c[1]))) 113 | if p[2] != c[2]: 114 | anomalies.append(C("homedir for user %s changed from %s to %s" % (user, p[2], c[2]))) 115 | if p[3] != c[3]: 116 | anomalies.append(C("shell for user %s changed from %s to %s" % (user, p[3], c[3]))) 117 | prev_pw, cur_pw = p[4], c[4] 118 | if is_gshadow(prev_pw) != is_gshadow(cur_pw): 119 | from_file = "shadow" if is_gshadow(prev_pw) else "passwd" 120 | to_file = "shadow" if is_gshadow(cur_pw) else "passwd" 121 | anomalies.append(C("password for user %s moved from /etc/%s to /etc/%s" % (user, from_file, to_file))) 122 | if is_gshadow(cur_pw) or is_empty(cur_pw): 123 | shadow_users.append(user) 124 | if is_empty(cur_pw): 125 | anomalies.append(W("password for user %s set to empty (might allow login)" % (user))) 126 | 127 | # for shadow users check we only care about the current file to display warnings 128 | in_passwd = list(set(cur.keys()) ^ set(shadow_users)) 129 | l = len(in_passwd) 130 | if l == 0: 131 | anomalies.append(D("all users have entries for passwords in /etc/shadow file as they should")) 132 | return anomalies 133 | anomalies.append(W("%i password%s for users not in /etc/shadow but /etc/passwd [%s]" % (l, "s" if l != 1 else "", ",".join(in_passwd)))) 134 | return anomalies 135 | -------------------------------------------------------------------------------- /dawgmon/commands/ipc.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | def parse_ipcs_output(output): 4 | res = {} 5 | lines = output.splitlines() 6 | for line in lines: 7 | parts = line.split() 8 | # ignore non-table rows and the table header 9 | if len(parts) < 5 or parts[0] == "------" or parts[0] == "key": 10 | continue 11 | key = int(parts[0], 16) 12 | owner, perms, size = parts[2:5] 13 | res[int(parts[1])] = (key, owner, perms, int(size)) 14 | return res 15 | 16 | class ListSharedMemorySegmentsCommand(Command): 17 | name = "list_shm" 18 | shell = False 19 | command = "ipcs -m" 20 | desc = "analyze changes in System V shared memory segments" 21 | 22 | def parse(output): 23 | return parse_ipcs_output(output) 24 | 25 | def compare(prev, cur): 26 | anomalies = [] 27 | segments = merge_keys_to_list(prev, cur) 28 | for shmid in segments: 29 | if shmid not in cur: 30 | p = prev[shmid] 31 | anomalies.append(C("shared memory segment %i destroyed (key=0x%x, owner=%s, permissions=%s, size=%i)" % (shmid, p[0], p[1], p[2], p[3]))) 32 | continue 33 | elif shmid not in prev: 34 | c = cur[shmid] 35 | anomalies.append(C("shared memory segment %i created (key=0x%x, owner=%s, permissions=%s, size=%i)" % (shmid, c[0], c[1], c[2], c[3]))) 36 | continue 37 | p, c = prev[shmid], cur[shmid] 38 | if p[0] != c[0]: 39 | anomalies.append(C("key for shared memory segment %i changed from 0x%x to 0x%x" % (shmid, p[0], c[0]))) 40 | if p[1] != c[1]: 41 | anomalies.append(C("owner of shared memory segment %i changed from %s to %s" % (shmid, p[1], c[1]))) 42 | if p[2] != c[2]: 43 | anomalies.append(C("permissions for shared memory segment %i changed from %s to %s" % (shmid, p[2], c[2]))) 44 | if p[3] != c[3]: 45 | anomalies.append(C("size of shared memory segment %i changed from %i to %i" % (shmid, p[3], c[3]))) 46 | anomalies.append(D("shared memory segment %i (key=0x%x, owner=%s, permissions=%s and size=%i) unchanged" % (shmid, p[0], p[1], p[2], p[3]))) 47 | return anomalies 48 | 49 | class ListSemaphoreArraysCommand(Command): 50 | name = "list_sem" 51 | shell = False 52 | command = "ipcs -s" 53 | desc = "analyze changes in System V sempahores" 54 | 55 | def parse(output): 56 | return parse_ipcs_output(output) 57 | 58 | def compare(prev, cur): 59 | anomalies = [] 60 | semaphores = merge_keys_to_list(prev, cur) 61 | for sem in semaphores: 62 | if sem not in cur: 63 | p = prev[sem] 64 | anomalies.append(C("semaphore array %i destroyed (key=0x%x, owner=%s, permissions=%s, nsems=%i)" % (sem, p[0], p[1], p[2], p[3]))) 65 | continue 66 | elif sem not in prev: 67 | c = cur[sem] 68 | anomalies.append(C("semaphore array %i created (key=0x%x, owner=%s, permissions=%s, nsems=%i)" % (sem, c[0], c[1], c[2], c[3]))) 69 | continue 70 | p, c = prev[sem], cur[sem] 71 | if p[0] != c[0]: 72 | anomalies.append(C("key for semaphore array %i changed from 0x%x to 0x%x" % (sem, p[0], c[0]))) 73 | if p[1] != c[1]: 74 | anomalies.append(C("owner of semaphore array %i changed from %s to %s" % (sem, p[1], c[1]))) 75 | if p[2] != c[2]: 76 | anomalies.append(C("permissions for semaphore array %i changed from %s to %s" % (sem, p[2], c[2]))) 77 | if p[3] != c[3]: 78 | anomalies.append(C("number of semaphores in semaphore array %i changed from %i to %i" % (sem, p[3], c[3]))) 79 | anomalies.append(D("semaphore array %i (key=0x%x, owner=%s, permissions=%s, nsems=%i) unchanged" % (sem, p[0], p[1], p[2], p[3]))) 80 | return anomalies 81 | 82 | class ListMessageQueuesCommand(Command): 83 | name = "list_msq" 84 | shell = False 85 | command = "ipcs -q" 86 | desc = "analyze changes in System V message queues" 87 | 88 | def parse(output): 89 | return parse_ipcs_output(output) 90 | 91 | def compare(prev, cur): 92 | anomalies = [] 93 | queues = merge_keys_to_list(prev, cur) 94 | for q in queues: 95 | if q not in cur: 96 | p = prev[q] 97 | anomalies.append(C("message queue %i destroyed (key=0x%x, owner=%s, permissions=%s, used-bytes=%i)" % (q, p[0], p[1], p[2], p[3]))) 98 | continue 99 | elif q not in prev: 100 | c = cur[q] 101 | anomalies.append(C("message queue %i created (key=0x%x, owner=%s, permissions=%s, used-bytes=%i)" % (q, c[0], c[1], c[2], c[3]))) 102 | continue 103 | p, c = prev[q], cur[q] 104 | if p[0] != c[0]: 105 | anomalies.append(C("key for message queue %i changed from 0x%x to 0x%x" % (q, p[0], c[0]))) 106 | if p[1] != c[1]: 107 | anomalies.append(C("owner of message queue %i changed from %s to %s" % (q, p[1], c[1]))) 108 | if p[2] != c[2]: 109 | anomalies.append(C("permissions for message queue %i changed from %s to %s" % (q, p[2], c[2]))) 110 | if p[3] != c[3]: 111 | anomalies.append(C("used-bytes of message queue %i changed from %i to %i" % (q, p[3], c[3]))) 112 | anomalies.append(D("message queue %i (key=0x%x, owner=%s, permissions=%s, used-bytes=%i) unchanged" % (q, p[0], p[1], p[2], p[3]))) 113 | return anomalies 114 | 115 | class ListListeningUNIXSocketsCommand(Command): 116 | name = "list_unix_ports" 117 | shell = False 118 | command = "netstat -lx" 119 | desc = "list changes in listening UNIX ports" 120 | 121 | def parse(output): 122 | res = {} 123 | output = output.splitlines()[2:] 124 | for line in output: 125 | parts = line.split()[-4:] 126 | if parts[1] != "LISTENING": 127 | # simple sanity check 128 | raise Exception("unexpected output") 129 | i_node = int(parts[2]) 130 | sock_name = parts[3] 131 | sock_type = parts[0] 132 | res[sock_name] = (i_node, sock_type) 133 | return res 134 | 135 | def compare(prev, cur): 136 | anomalies = [] 137 | sockets = merge_keys_to_list(prev, cur) 138 | for sock in sockets: 139 | if sock not in cur: 140 | anomalies.append(C("listening UNIX socket %s closed" % sock)) 141 | continue 142 | elif sock not in prev: 143 | anomalies.append(C("listening UNIX socket %s opened" % sock)) 144 | continue 145 | p, c = prev[sock], cur[sock] 146 | if p[0] != c[0]: 147 | anomalies.append(C("i-node for listening UNIX socket %s changed from %i to %i" % (sock, p[0], c[0]))) 148 | if p[1] != c[1]: 149 | anomalies.append(C("type of listening UNIX socket %s changed from %s to %s" % (sock, p[1], c[1]))) 150 | return anomalies 151 | -------------------------------------------------------------------------------- /dawgmon/commands/systemd.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | 3 | SYSTEMCTL_BIN = "/bin/systemctl" 4 | 5 | def remove_footer_from_table(output): 6 | # remove the empty lines and footers at the end 7 | lines = output.splitlines() 8 | if len(lines) == 0: 9 | return [] 10 | full_lines = [] 11 | for l in lines: 12 | if len(l) == 0: 13 | break 14 | full_lines.append(l) 15 | return full_lines 16 | 17 | # output tables in systemd are made up relatively uniformly so this function has the ability to parse them into a list of dictionaries with one dictonary for each row. Each dictionary's keys are 18 | # automatically derived from the table header. 19 | def parse_systemd_output_table(output): 20 | res = [] 21 | lines = remove_footer_from_table(output) 22 | if len(lines) == 0: 23 | return res 24 | # parse header to get data offsets for entries 25 | headers = lines[0] 26 | hs = headers.split() 27 | offsets = [headers.find(h) for h in hs] 28 | # use lowercase headers for entries in dict entries 29 | headers = [h.lower() for h in hs] 30 | # parse and add each entry ignoring header and last two lines as those are either empty or showing the total entry count 31 | for line in lines[1:]: 32 | j = 0 33 | e = {} 34 | lo = len(offsets) 35 | for i, start in enumerate(offsets): 36 | if i < lo-1: 37 | end = offsets[i+1] 38 | else: 39 | end = len(line) 40 | e[headers[j]] = line[start:end].strip() 41 | j = j + 1 42 | res.append(e) 43 | return res 44 | 45 | class ListSystemDSocketsCommand(Command): 46 | name = "systemd_sockets" 47 | shell = False 48 | command = "%s list-sockets --full" % (SYSTEMCTL_BIN) 49 | desc = "list systemd sockets" 50 | 51 | def parse(output): 52 | res = {} 53 | entries = parse_systemd_output_table(output) 54 | for e in entries: 55 | res[e["listen"]] = (e["unit"], e["activates"]) 56 | return res 57 | 58 | def compare(prev, cur): 59 | anomalies = [] 60 | sockets = merge_keys_to_list(prev, cur) 61 | for listen in sockets: 62 | if listen not in prev: 63 | anomalies.append(C("systemd socket %s added" % listen)) 64 | continue 65 | elif listen not in cur: 66 | anomalies.append(C("systemd socket %s removed" % listen)) 67 | continue 68 | p, c = prev[listen], cur[listen] 69 | if p[0] != c[0]: 70 | anomalies.append(C("systemd socket %s came from unit %s but now comes from %s" % (listen, p[0], c[0]))) 71 | if p[1] != c[1]: 72 | anomalies.append(C("systemd socket %s used to activate %s but now activates %s" % (listen, p[1], c[1]))) 73 | return anomalies 74 | 75 | class ListSystemDTimersCommand(Command): 76 | name = "systemd_timers" 77 | shell = False 78 | command = "%s list-timers --all --full" % (SYSTEMCTL_BIN) 79 | desc = "list systemd timers" 80 | 81 | def parse(output): 82 | res = {} 83 | entries = parse_systemd_output_table(output) 84 | for e in entries: 85 | res[e["unit"]] = (e["activates"], e["last"], e["next"], e["left"], e["passed"]) 86 | return res 87 | 88 | def compare(prev, cur): 89 | anomalies = [] 90 | units = merge_keys_to_list(prev, cur) 91 | for unit in units: 92 | if unit not in prev: 93 | anomalies.append(C("systemd timer %s added" % unit)) 94 | continue 95 | elif unit not in cur: 96 | anomalies.append(C("systemd timer %s removed" % unit)) 97 | continue 98 | p, c = prev[unit], cur[unit] 99 | if p[0] != c[0]: 100 | anomalies.append(C("systemd timer %s used to activate %s but now %s" % (unit, p[0], c[0]))) 101 | anomalies.append(D("systemd timer %s ran at %s (%s) and will run again at %s (%s)" % (unit, c[1], c[3], c[2], c[4]))) 102 | return anomalies 103 | 104 | class ListSystemDUnitsCommand(Command): 105 | name = "systemd_units" 106 | shell = False 107 | command = "%s --all --full" % (SYSTEMCTL_BIN) 108 | desc = "list all available systemd units" 109 | 110 | def parse(output): 111 | res = {} 112 | entries = parse_systemd_output_table(output) 113 | for e in entries: 114 | res[e["unit"]] = (e["active"], e["load"], e["sub"], e["description"]) 115 | return res 116 | 117 | def compare(prev, cur): 118 | anomalies = [] 119 | units = merge_keys_to_list(prev, cur) 120 | for unit in units: 121 | if unit not in prev: 122 | anomalies.append(C("systemd unit '%s' added" % unit)) 123 | continue 124 | elif unit not in cur: 125 | anomalies.append(C("systemd unit '%s' removed" % unit)) 126 | continue 127 | p, c = prev[unit], cur[unit] 128 | pstatus, cstatus = p[0], c[0] 129 | if pstatus != cstatus: 130 | anomalies.append(C("systemd unit '%s' status changed from '%s' to '%s'" % (unit, pstatus, cstatus))) 131 | pload, cload = p[1], c[1] 132 | if pload != cload: 133 | anomalies.append(C("systemd unit '%s' load changed from '%s' to '%s'" % (unit, pload, cload))) 134 | psub, csub = p[2], c[2] 135 | if psub != csub: 136 | anomalies.append(C("systemd unit '%s' sub changed from '%s' to '%s'" % (unit, psub, csub))) 137 | pdesc, cdesc = p[3], c[3] 138 | if pdesc != cdesc: 139 | anomalies.append(C("systemd unit '%s' description changed from '%s' to '%s'" % (unit, pdesc, csub))) 140 | anomalies.append(D("systemd unit '%s' was '%s' with status '%s' and sub is '%s'" % (unit, cload, cstatus, csub))) 141 | return anomalies 142 | 143 | class ListSystemDUnitFilesCommand(Command): 144 | name = "systemd_unitfiles" 145 | shell = False 146 | command = "%s list-unit-files --all --full" % (SYSTEMCTL_BIN) 147 | desc = "list all available systemd unit files" 148 | 149 | def parse(output): 150 | res = {} 151 | # hack as the header detection won't work otherwise in the utility function used below because there's a space in the first header 'UNIT FILE' 152 | lines = output.splitlines() 153 | if len(lines) == 0: # happens for default no output 154 | return res 155 | lines[0] = lines[0].replace("UNIT FILE", "UNIT_FILE") 156 | output = "\n".join(lines) 157 | entries = parse_systemd_output_table(output) 158 | for e in entries: 159 | res[e["unit_file"]] = e["state"] 160 | return res 161 | 162 | def compare(prev, cur): 163 | anomalies = [] 164 | units = merge_keys_to_list(prev, cur) 165 | for unit in units: 166 | if unit not in prev: 167 | anomalies.append(C("systemd unit file %s added" % unit)) 168 | continue 169 | elif unit not in cur: 170 | anomalies.append(C("systemd unit file %s removed" % unit)) 171 | continue 172 | p, c = prev[unit], cur[unit] 173 | if p != c: 174 | anomalies.append(C("systemd unit file %s status changed from %s to %s" % (unit, p, c))) 175 | anomalies.append(D("systemd unit file %s has status %s" % (unit, c))) 176 | return anomalies 177 | 178 | class ListSystemDPropertiesCommand(Command): 179 | name = "systemd_props" 180 | shell = False 181 | command = "%s show --all --full" % (SYSTEMCTL_BIN) 182 | desc = "show all systemd properties" 183 | 184 | def parse(output): 185 | res = {} 186 | lines = output.splitlines() 187 | for line in lines: 188 | lf = line.find("=") 189 | name = line[0:lf] 190 | value = line[lf+1:].strip() 191 | res[name] = value 192 | return res 193 | 194 | def compare(prev, cur): 195 | anomalies = [] 196 | properties = merge_keys_to_list(prev, cur) 197 | for propname in properties: 198 | anomalies.append(D("systemd property %s = %s" % (propname, cur[propname]))) 199 | if propname not in prev: 200 | anomalies.append(C("systemd property %s added" % propname)) 201 | continue 202 | elif propname not in cur: 203 | anomalies.append(C("systemd property %s removed" % propname)) 204 | continue 205 | p, c = prev[propname], cur[propname] 206 | if p != c: 207 | anomalies.append(C("systemd property %s changed from %s to %s" % (propname, p, c))) 208 | return anomalies 209 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | dawgmon - Dawg the Hallway Monitor 2 | 3 | SUMMARY 4 | 5 | The name of this tool is based upon an episode (season 10, episode 10) of South 6 | Park in which Cartman is Dawg the Hallway Monitor patrolling the hallways of 7 | his school. It's a tool which helps one to monitor changes which have taken 8 | place on a Linux-based system since the previous time the tool was ran. 9 | 10 | One way to use it is to use something like the included sample cronjob to run 11 | dawgmon on a regular interval and email the results to the system 12 | administrator. This can help with identifying machines upon which nefarious 13 | things are happening and monitor who's installing what and where. Please note 14 | that any serious kernel backdoor will easily be able to hide itself from this 15 | tool and as such it's just one added tool in one's toolkit but it should not 16 | relied upon for full security monitoring of Linux machines. It's just one extra 17 | option in one's toolbox. 18 | 19 | The other way it's useful is by generating a baseline before installing a piece 20 | of software. Then after installing this piece of software one will run the tool 21 | again and then it's easy to see which changes were made on the system. An 22 | example after establishing a baseline and then installing virtualbox on a 23 | machine might yield something like this: 24 | 25 | # ./dawgmon -gfA 26 | 1 change detected (0 warnings) 27 | + systemd property NNames changed from 259 to 261 28 | # apt install virtualbox-5.1 29 | [...] 30 | # ./dawgmon -gfA 31 | 33 changes detected (0 warnings) 32 | + size of file /etc/group changed from 937 to 954 33 | + file /etc/group got modified on 2017-09-14 19:29:51.804811 +0200 34 | + size of file /etc/group- changed from 934 to 937 35 | + file /etc/group- got modified on 2017-09-14 19:29:14.000000 +0200 36 | + file /etc/gshadow got modified on 2017-09-14 19:29:51.812811 +0200 37 | + size of file /etc/gshadow- changed from 777 to 794 38 | + size of file /etc/mailcap changed from 40777 to 41063 39 | + file /etc/mailcap got modified on 2017-09-14 19:29:51.632812 +0200 40 | + file /etc/systemd/system/multi-user.target.wants/vboxautostart-service.service got created (owner=root, group=root, perm=lrwxrwxrwx, size=49) 41 | + file /etc/systemd/system/multi-user.target.wants/vboxballoonctrl-service.service got created (owner=root, group=root, perm=lrwxrwxrwx, size=51) 42 | + file /etc/systemd/system/multi-user.target.wants/vboxdrv.service got created (owner=root, group=root, perm=lrwxrwxrwx, size=35) 43 | + file /etc/systemd/system/multi-user.target.wants/vboxweb-service.service got created (owner=root, group=root, perm=lrwxrwxrwx, size=43) 44 | + file /etc/udev/rules.d/60-vboxdrv.rules got created (owner=root, group=root, perm=-rw-r--r--, size=747) 45 | + group vboxusers added 46 | + package virtualbox-5.1 is to be installed 47 | + suid binary /usr/lib/virtualbox/VBoxHeadless got created (owner=root, group=root, perm=-r-s--x--x, size=158304) 48 | + suid binary /usr/lib/virtualbox/VBoxNetAdpCtl got created (owner=root, group=root, perm=-r-s--x--x, size=23144) 49 | + suid binary /usr/lib/virtualbox/VBoxNetDHCP got created (owner=root, group=root, perm=-r-s--x--x, size=158304) 50 | + suid binary /usr/lib/virtualbox/VBoxNetNAT got created (owner=root, group=root, perm=-r-s--x--x, size=158304) 51 | + suid binary /usr/lib/virtualbox/VBoxSDL got created (owner=root, group=root, perm=-r-s--x--x, size=158296) 52 | + suid binary /usr/lib/virtualbox/VBoxVolInfo got created (owner=root, group=root, perm=-r-s--x--x, size=10472) 53 | + suid binary /usr/lib/virtualbox/VirtualBox got created (owner=root, group=root, perm=-r-s--x--x, size=158304) 54 | + i-node for listening UNIX socket /run/systemd/private changed from 3428734 to 3452848 55 | + systemd property NInstalledJobs changed from 8392199 to 3238035463 56 | + systemd property NNames changed from 261 to 263 57 | + systemd unit file vboxautostart-service.service added 58 | + systemd unit file vboxballoonctrl-service.service added 59 | + systemd unit file vboxdrv.service added 60 | + systemd unit file vboxweb-service.service added 61 | + systemd unit 'vboxautostart-service.service' added 62 | + systemd unit 'vboxballoonctrl-service.service' added 63 | + systemd unit 'vboxdrv.service' added 64 | + systemd unit 'vboxweb-service.service' added 65 | 66 | The above helps with now doing a thorough security review of virtualbox. The 67 | installed suid binaries are obvious points of entry and the run services are 68 | interesting. 69 | 70 | Another example run which correctly detects TCP ports opening and closing: 71 | 72 | # ./dawgmon -gfA 73 | 0 changes detected (0 warnings) 74 | # nc -l -p 4455 & 75 | [1] 12489 76 | # ./dawgmon -gfA 77 | 1 change detected (0 warnings) 78 | + port 4455 tcp opened 79 | # fg 80 | nc -l -p 4455 81 | ^C 82 | # ./dawgmon -gfA 83 | 1 change detected (0 warnings) 84 | + port 4455 tcp closed 85 | # 86 | 87 | The tool is not meant for complete accuracy. There are very serious 88 | recommendations normally to not rely on the output of GNU core-utils such as ls 89 | for tool input. In other words; one should rarely build tools to parse and rely 90 | on this type of output as it can change all the time. Realistically the output 91 | of these tools is relatively stable as a lot of people and automatic tools 92 | already rely on their outputs for all kinds of purposes. 93 | 94 | However the tradeoff for dawgmon is the following; we would need to implement a 95 | lot of logic to do file system monitoring ourselves, build complex binaries 96 | that include libraries to do the parsing and monitoring of block devices, the 97 | network interfaces and what not more. This will also make the tool way more 98 | complex and less maintainable. On projects right now one can add a new command 99 | including change detection in very little time as the main dawgmon tool already 100 | takes care of caching, executing the command and then supplying the previous 101 | and current output when running a comparision to a command implementation. This 102 | means that on time-constrained projects one can very quickly add a new command 103 | and run analysises including those new commands. 104 | 105 | A command can be added by simply inheriting from the Command class. This class 106 | is defined in commands/__init__.py. That file contains also the master list of 107 | commands (and the order in which they're executed when doing a full analysis). 108 | Then the properties like 'name', 'shell', 'command' and 'desc' will have to be 109 | set and two methods 'parse()' and 'compare()' will have to be implemented. 110 | Enough commands are included to get a good idea on how to implement and add new 111 | ones. 112 | 113 | 114 | USAGE 115 | 116 | For best results run the tool as root. For help type -h/--help and for version 117 | information type -v/--version. 118 | 119 | A main action will always have to be specified. These actions are: 120 | -A: analyze the system 121 | -C: compare cache entries 122 | -E: list available commands 123 | -L: list cache entries 124 | 125 | # runs an analysis 126 | dawgmon -A 127 | 128 | # runs an analysis but only with a few commands 129 | dawgmon -A -e list_suids -e list_tcpudp_ports 130 | 131 | # shows the list of available commands 132 | dawgmon -E 133 | 134 | # shows available cache entries for comparison 135 | dawgmon -L 136 | 137 | # compare old cache entry 3 with new cache entry 5 138 | dawgmon -C 3 5 139 | 140 | Further options to help with the analysis are: 141 | 142 | -d: show debug output 143 | -e: execute a specific command (can be used multiple times) 144 | -f: force a run without warning about running as root 145 | -g: colorize the output 146 | -l: location of the database cache to use 147 | -m: maximum amount of cache entries to allow in cache (if cache has more 148 | entries than this amount it will truncate the database) 149 | -t: do not output timestamp information per detected anomaly. 150 | 151 | For more usage information run the tool with -h. 152 | 153 | 154 | LIMITATIONS 155 | 156 | The tool parses output of commandline tools and relies on GNU core-utils 157 | specific options for some of it. There's no specific reason why this tool and 158 | the command implementations cannot be quickly ported to other operating systems 159 | such as the BSD's but right now no Operating System detection is taking place 160 | nor is there a classification of commands based on Operating System support 161 | being done. That would have to be implemented first. 162 | 163 | Regarding the finding of pipes, UNIX sockets, files in /boot, /etc and more one 164 | should note that the usage of -xdev passed to 'find' means that not all 165 | filesystems mounted under the start directory will be traversed. This means 166 | that for example /boot will be properly scanned but /boot/efi might not be. 167 | Some smarter approach will have to be used here in the future. 168 | 169 | The timestamps when doing a current analysis might be a bit confusing. By 170 | default a timestamp for a warning, normal or debug anomaly message will be the 171 | timestamp of its generation time (as can be seen in commands/__init__.py). The 172 | anomalies are generated after a full commandline scan has been done. So the 173 | output upon which this detection works was generated earlier and subsequently 174 | the timestamps for individual anomalies show a time after the time of the scan. 175 | When comparing cache entries the timestamp of the scan is being used to output 176 | the time at which the detection happend as that makes logically simply more 177 | sense. 178 | 179 | 180 | ABOUT 181 | 182 | All rights reserved. Copyright (C) 2017-2019 by Anvil Ventures Inc. 183 | For licensing information see LICENSE. 184 | For more information contact Vincent Berg 185 | 186 | To find updated source code or to contribute patches go to the following URL: 187 | https://github.com/anvilventures/dawgmon/ 188 | -------------------------------------------------------------------------------- /dawgmon/dawgmon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, os, tempfile, functools 4 | from datetime import datetime 5 | from argparse import ArgumentParser 6 | 7 | import dawgmon.commands as commands 8 | from dawgmon.utils import merge_keys_to_list, ts_to_str 9 | from dawgmon.cache import Cache 10 | from dawgmon.local import local_run 11 | from dawgmon.version import VERSION 12 | 13 | def compare_output(old, new, commandlist=None, replace_timestamp=False, timestamps=(None, None)): 14 | anomalies = [] 15 | if not old: 16 | # if None is passed in it's simply empty 17 | old = {} 18 | tasks = merge_keys_to_list(old, new) 19 | for task_name in tasks: 20 | cmd = commands.COMMAND_CACHE.get(task_name, None) 21 | if not cmd: 22 | anomalies.append(commands.W("unknown command with name %s found (cache generated by older version?)" % (task_name))) 23 | continue 24 | if commandlist and task_name not in commandlist: 25 | continue 26 | 27 | # cache contains (stdout, stderr) of the executed tasks 28 | old_data = old[task_name][0] if task_name in old else "" 29 | old_data = cmd.parse(old_data) 30 | new_data = cmd.parse(new[task_name][0]) 31 | ret = cmd.compare(old_data, new_data) 32 | if type(ret) != list: 33 | raise Exception("unexpected return value type for %s" % cmd) 34 | 35 | # replace the timestamp which we need to do if we're for 36 | # example comparing two historical cache entries as the 37 | # timestamps of the generated anomalies then will be based upon 38 | # when the anomaly is being detected which is a runtime 39 | # timestamp and not a timestamp associated with the cache 40 | # entry. 41 | if replace_timestamp: 42 | ret = [(r[0], r[1], timestamps[1]) for r in ret] 43 | if ret and len(ret) > 0: 44 | anomalies = anomalies + ret 45 | return anomalies 46 | 47 | def get_ts(ts, show_timestamp=True): 48 | return "[%s] " % ts_to_str(ts) if show_timestamp else "" 49 | 50 | def print_anomalies(anomalies, show_debug=False, show_color=True, show_timestamp=True, timestamps=(None, None)): 51 | changes = list(filter(lambda x:x[0] == commands.CHANGE, anomalies)) 52 | warning = list(filter(lambda x:x[0] == commands.WARNING, anomalies)) 53 | debug = list(filter(lambda x:x[0] == commands.DEBUG, anomalies)) if show_debug else [] 54 | c1 = "\x1b[32m" if show_color else "" 55 | c2 = "\x1b[31m" if show_color else "" 56 | c3 = "\x1b[36m" if show_color else "" 57 | c4 = "\x1b[34m" if show_color else "" 58 | c_end = "\x1b[0m" if show_color else "" 59 | la, lw, ld = len(changes), len(warning), len(debug) 60 | debugs = " and %i debug message%s" % (ld, "s" if ld != 1 else "") if ld > 0 else "" 61 | timestamps = [ts_to_str(t) for t in list(timestamps)] 62 | betweens = " between [%s] and [%s] " % (timestamps[0], timestamps[1]) if show_timestamp else "" 63 | print("%s%i change%s detected (%i warning%s%s)%s%s" % (c1, la, "s" if la != 1 else "", lw, "s" if lw != 1 else "", debugs, betweens, c_end)) 64 | for w in warning: 65 | print("%s%s! %s%s" % (c2, get_ts(w[2], show_timestamp), w[1], c_end)) 66 | for c in changes: 67 | print("%s%s+ %s%s" % (c3, get_ts(c[2], show_timestamp), c[1], c_end)) 68 | if show_debug: 69 | for d in debug: 70 | print("%s%s- %s%s" % (c4, get_ts(d[2], show_timestamp), d[1], c_end)) 71 | 72 | def run(tmpdirname): 73 | 74 | default_max_cache_entries = 16 75 | default_cache_name = ".dawgmon.db" 76 | 77 | # parsing and checking arguments 78 | parser = ArgumentParser(description="attack surface analyzer and change monitor") 79 | 80 | group = parser.add_mutually_exclusive_group() 81 | group.add_argument("-A", help="analyze system", dest="analyze", action="store_true", default=False) 82 | group.add_argument("-C", help="compare cache entry id1 with id2", dest="compare_cache", metavar=("id1", "id2"), nargs=2, type=int) 83 | group.add_argument("-E", help="list available commands", dest="list_commands", action="store_true", default=False) 84 | group.add_argument("-L", help="list cache entries", dest="list_cache", action="store_true", default=False) 85 | 86 | parser.add_argument("-d", help="show debug output", dest="show_debug", action="store_true", default=False) 87 | parser.add_argument("-e", help="execute specific command", dest="commandlist", metavar="command", type=str, action="append") 88 | parser.add_argument("-f", help="force action even if not seteuid root", dest="force", default=False, action="store_true") 89 | parser.add_argument("-g", help="colorize the analysis output", dest="colorize", default=False, action="store_true") 90 | parser.add_argument("-l", help="location of database cache (default: $HOME/%s)" % (default_cache_name), dest="cache_location", metavar="filename", default=None, required=False) 91 | parser.add_argument("-m", help="max amount of cache entries per host (default: %i)" % default_max_cache_entries, 92 | dest="max_cache_entries", type=int, metavar="N", default=default_max_cache_entries, required=False) 93 | parser.add_argument("-t", help="do not output timestamps", dest="show_timestamps", default=True, action="store_false") 94 | parser.add_argument("-v", "--version", action="version", version="dawgmon %s" % VERSION) 95 | args = parser.parse_args() 96 | 97 | isatty = os.isatty(sys.stdout.fileno()) 98 | 99 | if args.max_cache_entries < 1 or args.max_cache_entries > 1024: 100 | print("maximum number of cache entries invalid or set too high [1-1024]") 101 | sys.exit(1) 102 | 103 | if not args.cache_location: 104 | args.cache_location = os.path.join(os.getenv("HOME"), default_cache_name) 105 | 106 | if not args.list_cache and not args.list_commands and not args.analyze and not args.compare_cache: 107 | print("select an action -A/C/E/L") 108 | return 109 | 110 | if not args.force and os.geteuid() != 0 and args.analyze: 111 | print("It's strongly recommended to run an analysis as root.") 112 | if not isatty: 113 | print("Running non-interactively (not on a TTY) so bailing out.") 114 | print("Either run again with -f or run again as root.") 115 | return 116 | answer = input("Continue anyway with the analysis y/n? ") 117 | if len(answer) != 1 or answer[0].lower() != 'y': 118 | return 119 | 120 | 121 | # load last entry from cache 122 | cache = Cache(args.cache_location) 123 | cache.load() 124 | 125 | # list all the entries available in the cache 126 | if args.list_cache: 127 | entries = cache.get_entries() 128 | print(" ID\tTIMESTAMP") 129 | for entry in entries: 130 | print("{:4d}\t%s".format(entry["id"]) % (entry["timestamp"])) 131 | return 132 | # list all the commands available 133 | elif args.list_commands: 134 | cmd_list = list(commands.COMMAND_CACHE.keys()) 135 | ml = max([len(x) for x in cmd_list]) 136 | cmd_list.sort() 137 | print("%s\t%s" % ("NAME".ljust(ml), "DESCRIPTION")) 138 | for cmd_name in cmd_list: 139 | print("%s\t%s" % (cmd_name.ljust(ml), commands.COMMAND_CACHE[cmd_name].desc)) 140 | return 141 | 142 | # only add results to cache if a full analysis was run 143 | add_to_cache = not args.commandlist and args.analyze 144 | 145 | # if no commandlist specified add all available commands 146 | if not args.commandlist: 147 | args.commandlist = [] 148 | for cmd in commands.COMMANDS: 149 | args.commandlist.append(cmd.name) 150 | 151 | 152 | # run the selected list of commands or get cached results 153 | anomalies = [] 154 | if args.analyze: 155 | new = {} 156 | lc = len(args.commandlist) 157 | done = 0 158 | cmd_runner = local_run(tmpdirname, args.commandlist) 159 | for res in cmd_runner: 160 | # if we're running on a TTY we're in interactive mode 161 | # so we try to show some updates regarding progress 162 | if isatty: 163 | print("%i/%i %s" % (done, lc, "[%s]".ljust(20) % (args.commandlist[done])), end="\r") 164 | cmd_name, cmd_exec, retcode, cmd_stdout, cmd_stderr = res 165 | done = done + 1 166 | new[cmd_name] = (cmd_stdout, cmd_stderr) 167 | if retcode == 0: 168 | continue 169 | print("%s failed with non-zero exit status (%i)" % (cmd_name, retcode)) 170 | 171 | # we default back to always showing stderr output if 172 | # we're not in interactive mode (which is f.e. the case 173 | # if we get run from a cronjob with the output piped to 174 | # a file for email purposes) 175 | if isatty and not args.show_debug: 176 | print("run again with debug (-d) turned on to see stderr output") 177 | return 178 | print("\n%s\n" % cmd_exec) 179 | print(cmd_stderr) 180 | return 181 | 182 | # add new entry to cache if needed but only if a full command list is being executed 183 | old = cache.get_last_entry() 184 | old_ts = cache.get_last_entry_timestamp() 185 | new_ts = datetime.utcnow() 186 | 187 | if add_to_cache: 188 | if not old: 189 | anomalies.append(commands.W("no cache entry found yet so caching baseline")) 190 | cache.add_entry(new, timestamp=new_ts) 191 | else: 192 | anomalies.append(commands.W("results NOT cached as only partial command list being run")) 193 | change_timestamps = False 194 | else: 195 | new = cache.get_entry(args.compare_cache[1]) 196 | if not new: 197 | print("cannot find cache entry with id %i" % args.compare_cache[1]) 198 | return 199 | old = cache.get_entry(args.compare_cache[0]) 200 | if not old: 201 | print("cannot find cache entry with id %i" % args.compare_cache[0]) 202 | return 203 | new_ts = cache.get_entry_timestamp(args.compare_cache[1]) 204 | old_ts = cache.get_entry_timestamp(args.compare_cache[0]) 205 | change_timestamps = True 206 | 207 | # merge the list of differences with the previous list this is done 208 | # such that the warnings added above will appear first when outputting 209 | # the warnings later on in print_anomalies 210 | anomalies = anomalies + compare_output(old, new, args.commandlist, change_timestamps, (old_ts, new_ts)) 211 | 212 | # output the detected anomalies 213 | print_anomalies(anomalies, args.show_debug, args.colorize, args.show_timestamps, (old_ts, new_ts)) 214 | 215 | # update the cache 216 | cache.purge(args.max_cache_entries) 217 | cache.save() 218 | 219 | def main(): 220 | with tempfile.TemporaryDirectory() as tmpdirname: 221 | run(tmpdirname) 222 | 223 | if __name__ == "__main__": 224 | main() 225 | --------------------------------------------------------------------------------