10 Commits ab51877602 ... 875eeae9b8

Author SHA1 Message Date
  guillaume 875eeae9b8 concierge-permaudit: new rules for postfix, wireguard 6 years ago
  guillaume 7bca71a558 concierg-permaudit: improve nginx discovery rule, fix regex, support relative paths 6 years ago
  guillaume 54cc40e361 concierge-permaudit: add rule for nginx htpasswd 6 years ago
  guillaume 85e53e9eb9 concierge-permaudit: report unix accounts with empty password 6 years ago
  guillaume 5bae46aa85 concierge-permaudit: report root process that listen to connections but have no unprivileged workers, add --severity option 7 years ago
  guillaume 1609196175 concierge-permaudit: improve interpreter detection when auditing processes 7 years ago
  root d60d57a959 concierge-permaudit: catch moved/deleted running executables and scripts 7 years ago
  root 78ca8cb71e concierge-backup: update example conf 7 years ago
  root 1459d74154 update doc 7 years ago
  root c5ed95e621 concierge-permaudit: small cleanup 7 years ago
4 changed files with 127 additions and 50 deletions
  1. 2 1
      README.md
  2. 2 1
      doc/examples/backup.cfg.example
  3. 0 0
      doc/examples/backup.exclude
  4. 123 48
      src/concierge-permaudit

+ 2 - 1
README.md

@@ -31,7 +31,8 @@ Configuration: none
 Audit filesystem permissions for possible security issues: 
 * World-readable private keys (ssh, Let's Encrypt) and passwords (Git, SVN, Sympa, Dolibarr, ...)
 * World-writable configuration files and scripts (/etc/init.d/*, /etc/profile, ...)
-* World-writable executable search path ($PATH), python search path, and perl include path
+* World-writable executable search path (ie $PATH), or perl/python/ruby search path
+* Process running a world-writable executable, or world-writable bash/perl/python script
 * Sensitive information stored in the wrong place (passwords in /etc/passwd rather than /etc/shadow)
 
 This tool only does file permissions checks, and does it imperfectly. 

+ 2 - 1
doc/examples/backup.cfg.example

@@ -5,7 +5,7 @@ BACKUP_DIR_INCL=/etc;/var/mail
 LOCAL_DIR=/var/backups
 
 # Remote backup destination
-REMOTE_ENABLE=true
+REMOTE_ENABLE=false
 REMOTE_USER=backupuser
 REMOTE_HOSTNAME=backupotron5000.example.net
 REMOTE_DIR=/var/backups
@@ -15,3 +15,4 @@ REMOTE_DIR=/var/backups
 
 # Backup encryption
 ENCRYPTION_ENABLE=false
+ENCRYPTION_PASSPHRASE_PATH=/etc/concierge/backup.passphrase

doc/examples/backup.exclude.example → doc/examples/backup.exclude


+ 123 - 48
src/concierge-permaudit

@@ -4,11 +4,13 @@
 import argparse
 import os
 import pwd
+import spwd
 import re
 import shutil
 import subprocess
 import sys
 import glob
+from enum import IntEnum
 from pathlib import Path
 
 # Third party modules
@@ -31,10 +33,15 @@ def get_ruby_searchpath():
     return []
 
 disRules = list()
-disRules.append(('/etc/apache2/sites-available/*', 'SSLCertificateKeyFile\s+(\S+)'))
-disRules.append(('/etc/dovecot/conf.d/10-ssl.conf', 'ssl_key\s*=\s*<(\S+)'))
-disRules.append(('/etc/nginx/sites-available/*', 'ssl_certificate_key\s+([^;]+);'))
-disRules.append(('/etc/postfix/main.cf', 'smtpd_tls_key_file\s*=\s*(\S+)'))
+disRules.append({'pathname': '/etc/apache2/sites-available/*', 're': 'SSLCertificateKeyFile\s+(\S+)'})
+disRules.append({'pathname': '/etc/dovecot/conf.d/10-ssl.conf', 're': 'ssl_key\s*=\s*<(\S+)'})
+disRules.append({'pathname': '/etc/nginx/sites-available/*', 'cwd': '/etc/nginx', 're': 'ssl_certificate_key\s+"?([^;]+)"?;'})
+disRules.append({'pathname': '/etc/nginx/sites-available/*', 'cwd': '/etc/nginx', 're': 'auth_basic_user_file\s+"?([^;]+)"?'})
+disRules.append({'pathname': '/etc/postfix/main.cf', 're': 'smtpd_tls_dkey_file\s*=\s*(\S+)'})
+disRules.append({'pathname': '/etc/postfix/main.cf', 're': 'smtpd_tls_key_file\s*=\s*(\S+)'})
+disRules.append({'pathname': '/etc/postfix/main.cf', 're': 'smtpd_tls_eckey_file\s*=\s*(\S+)'})
+disRules.append({'pathname': '/etc/postfix/main.cf', 're': 'smtpd_tls_dh512_param_file\s*=\s*(\S+)'})
+disRules.append({'pathname': '/etc/postfix/main.cf', 're': 'smtpd_tls_dh1024_param_file\s*=\s*(\S+)'})
 
 readPatterns = [
   '/etc/shadow',
@@ -90,6 +97,7 @@ readPatterns = [
   '/etc/letsencrypt/keys/*.pem',
   '/etc/cups/ssl/*.key',
   '/etc/unbound/*.key',
+  '/etc/wireguard/*.conf',
   ]
 
 writePatterns = [
@@ -133,13 +141,41 @@ writePatterns = [
   '~/.logout' # tcsh shell
   ]
 
-interps = set([
-  '/bin/sh',
-  '/bin/bash',
-  '/usr/bin/perl',
-  '/usr/bin/python',
-  '/usr/bin/python3'
-  ])
+searchinterps = [
+  'sh',
+  'dash',
+  'bash',
+  'fish',
+  'tcsh',
+  'zsh',
+  'node',
+  'perl',
+  'php',
+  'php-cli',
+  'python',
+  'python2',
+  'python2.7',
+  'python3',
+  'python3.4',
+  'python3.5',
+  'python3.6',
+  'tclsh'
+  ]
+
+interps = set()
+
+for interpname in searchinterps:
+  interp = shutil.which(interpname)
+  if interp != None:
+    interp = Path(interp)
+    interps.add(str(interp))
+    realinterp = interp.resolve()
+    if realinterp != interp:
+      interps.add(str(realinterp))
+
+def is_interpreter(path):
+  resolvedPath = Path(path).resolve()
+  return str(resolvedPath) in interps
 
 interpArgParse = argparse.ArgumentParser(description='Generic interpreter parser', add_help=False)
 interpArgParse.add_argument('interp', type=str)
@@ -150,6 +186,20 @@ interpArgParse.add_argument('script', type=str)
 UID_MIN=1000
 UID_MAX=60000
 
+class Severity(IntEnum):
+  EMERG = 0
+  ALERT = 1
+  CRIT = 2
+  ERR = 3
+  WARNING = 4
+  NOTICE = 5
+  INFO = 6
+  DEBUG = 7
+
+argparser = argparse.ArgumentParser()
+argparser.add_argument('--severity', type = int, default = 3, choices = list(map(int, Severity)))
+args = argparser.parse_args()
+
 homePaths = []
 for pw in pwd.getpwall():
   if pw.pw_uid in range(UID_MIN, UID_MAX):
@@ -167,13 +217,15 @@ def patternWalk(pattern):
 
 # Discover paths to file with sensible information
 for disRule in disRules:
-  disPattern = disRule[0]
-  disRe = re.compile(disRule[1])
+  disPattern = disRule['pathname']
+  disRe = re.compile(disRule['re'])
   for disPath in patternWalk(disPattern):
+    cwd = disRule.get('cwd', Path(disPath).cwd())
     disFile = open(disPath, 'r')
     for match in re.finditer(disRe, disFile.read()):
       for group in match.groups():
-        readPatterns.append(group)
+        absPath = Path(cwd, group)
+        readPatterns.append(str(absPath))
 
 writePatternsParents = [
   ]
@@ -230,40 +282,50 @@ def isWorldWritable(path):
 
 exceptions = ''
 
-def logExceptions(message, pathList):
-  global exceptions
-  if len(pathList) > 0:
-    exceptions += "%s: \n" % message
-    for path in (pathList):
-      exceptions += " * %s\n" % path.as_posix()
-    exceptions += "\n"
-
-def logException(description, path, context = None):
+def logExceptions(description, paths = [], context = None, severity = 3):
   global exceptions
+  if severity > args.severity:
+    return
   exceptions += "%s\n" % description
-  exceptions += "  Path: %s\n" % path.as_posix()
   if context != None:
     exceptions += "  Context: %s\n" % context
+  if len(paths) == 1:
+    exceptions += "  Path: %s\n" % paths[0].as_posix()
+  elif len(paths) > 1:
+    exceptions += "  Paths: \n"
+    for path in paths:
+      exceptions += "   * %s\n" % path.as_posix()
   exceptions += "\n"
 
+def logWarnings(description, paths = [], context = None):
+  return logExceptions(description, paths, context, 4)
+
 def printExceptions():
   global exceptions
   print(exceptions, end='')
 
 def auditProcess(proc):
   ruid = proc.uids()[0]
-  pid = proc.pid
   exePathStr = proc.exe()
   if len(exePathStr) > 0:
     exePath = Path(exePathStr)
     try:
+      exePath = exePath.resolve()
       if (exePath.stat().st_uid != rootPwe.pw_uid and
           exePath.stat().st_uid != ruid):
-        logException('Executable is owned by another, non-root user', exePath.resolve(), 'Process %d' % proc.pid)
-    except:
-      pass
+        logExceptions('Executable is owned by another, non-root user', [exePath], 'Process %d' % proc.pid)
+    except FileNotFoundError:
+      logWarnings('Executable was moved or deleted', [exePath], 'Process %d' % proc.pid)
     if isWorldWritable(exePath):
-      logException('Executable is world-writable', exePath.resolve(), 'Process %d' % proc.pid)
+      logExceptions('Executable is world-writable', [exePath], 'Process %d' % proc.pid)
+  if ruid == rootPwe.pw_uid:
+    connlist = proc.connections(kind = 'inet')
+    connset = set(c for c in connlist if c.status == psutil.CONN_LISTEN)
+    if len(connset) > 0:
+      childlist = proc.children(recursive = True)
+      childset = set(p for p in childlist if p.uids()[0] != rootPwe.pw_uid)
+      if len(childset) == 0:
+        logExceptions('Root process listen for connections but has no unprivileged worker', [exePath], 'Process %s' % proc.pid, 5)
 
 def auditCommand(ruid, argList, cwd, env = {}, context = None):
   if 'PATH' in env:
@@ -271,20 +333,20 @@ def auditCommand(ruid, argList, cwd, env = {}, context = None):
   else:
     path = os.defpath
   absArg0 = shutil.which(argList[0], path=path)
-  if absArg0 in interps and len(argList) > 1:
-    (args, remainining) = interpArgParse.parse_known_args(argList)
-    scriptPath = Path(args.script)
+  if absArg0 != None and is_interpreter(absArg0) and len(argList) > 1:
+    (interpargs, remainining) = interpArgParse.parse_known_args(argList)
+    scriptPath = Path(interpargs.script)
     if not scriptPath.is_absolute():
-      scriptPath = Path(cwd, args.script)
+      scriptPath = Path(cwd, interpargs.script)
     try:
       scriptPath = scriptPath.resolve()
       if (scriptPath.stat().st_uid != rootPwe.pw_uid and
           scriptPath.stat().st_uid != ruid):
-        logException('Script is owned by another, non-root user', scriptPath.resolve(), context)
+        logExceptions('Script is owned by another, non-root user', [scriptPath], context)
+      if isWorldWritable(scriptPath):
+        logExceptions('Script is world-writable', [scriptPath], context)
     except FileNotFoundError:
-      pass # warning('File not found')
-    if isWorldWritable(scriptPath):
-      logException('Script is world-writable', scriptPath.resolve(), context)
+      logWarnings('Script was moved or deleted', [scriptPath], context)
 
 readExceptions = []
 for pattern in readPatterns:
@@ -293,7 +355,8 @@ for pattern in readPatterns:
     if isWorldReadable(path):
       readExceptions.append(path)
 
-logExceptions('These paths are world readable', readExceptions)
+if len(readExceptions) > 0:
+  logExceptions('These paths are world readable', readExceptions)
 
 writeExceptions = []
 for pattern in writePatterns:
@@ -302,7 +365,8 @@ for pattern in writePatterns:
     if isWorldWritable(path):
       writeExceptions.append(path)
 
-logExceptions('These paths are world-writable', writeExceptions)
+if len(writeExceptions):
+  logExceptions('These paths are world-writable', writeExceptions)
 
 parentWriteExceptions = []
 for pattern in writePatternsParents:
@@ -311,7 +375,8 @@ for pattern in writePatternsParents:
     if isWorldWritable(path):
       parentWriteExceptions.append(path)
 
-logExceptions('These paths are world-writable', parentWriteExceptions)
+if len(parentWriteExceptions) > 0:
+  logExceptions('These paths are world-writable', parentWriteExceptions)
 
 execpathWriteExceptions = []
 for strPath in os.get_exec_path():
@@ -319,7 +384,8 @@ for strPath in os.get_exec_path():
   if isWorldWritable(path):
     execpathWriteExceptions.append(path)
 
-logExceptions('These executable search paths are world-writable', execpathWriteExceptions)
+if len(execpathWriteExceptions) > 0:
+  logExceptions('These executable search paths are world-writable', execpathWriteExceptions)
 
 pythonpathWriteExceptions = []
 for strPath in sys.path:
@@ -327,7 +393,8 @@ for strPath in sys.path:
   if isWorldWritable(path):
     pythonpathWriteExceptions.append(path)
 
-logExceptions('These python search paths are world-writable', pythonpathWriteExceptions)
+if len(pythonpathWriteExceptions) > 0:
+  logExceptions('These python search paths are world-writable', pythonpathWriteExceptions)
 
 perlpathWriteExceptions = []
 for strPath in get_perl_searchpath():
@@ -335,7 +402,8 @@ for strPath in get_perl_searchpath():
   if isWorldWritable(path):
     perlpathWriteExceptions.append(path)
 
-logExceptions('These perl search paths are world-writable', perlpathWriteExceptions)
+if len(perlpathWriteExceptions) > 0:
+  logExceptions('These perl search paths are world-writable', perlpathWriteExceptions)
 
 rubypathWriteExceptions = []
 for strPath in get_ruby_searchpath():
@@ -343,17 +411,18 @@ for strPath in get_ruby_searchpath():
   if isWorldWritable(path):
     rubypathWriteExceptions.append(path)
 
-logExceptions('These ruby search paths are world-writable', rubypathWriteExceptions)
+if len(rubypathWriteExceptions) > 0:
+  logExceptions('These ruby search paths are world-writable', rubypathWriteExceptions)
 
 for proc in psutil.process_iter():
   pid = proc.pid
   ruid = proc.uids()[0]
-  args = proc.cmdline()
+  procargs = proc.cmdline()
   cwd = proc.cwd()
   env = proc.environ()
   auditProcess(proc)
-  if len(args) > 0:
-    auditCommand(ruid, args, cwd, env, 'Process %d' % pid)
+  if len(procargs) > 0:
+    auditCommand(ruid, procargs, cwd, env, 'Process %d' % pid)
 
 # Passwords should be stored in /etc/shadow, not /etc/passwd
 contentExceptions = []
@@ -361,6 +430,12 @@ for pw in pwd.getpwall():
   if len(pw.pw_passwd) > 0 and pw.pw_passwd != 'x' and pw.pw_passwd != '*' and pw.pw_passwd != '!':
     contentExceptions.append(Path('/etc/passwd'))
 
-logExceptions('These files contains sensible information', contentExceptions)
+if len(contentExceptions) > 0:
+  logExceptions('These files contains sensible information', contentExceptions)
+
+credExceptions = []
+for sp in spwd.getspall():
+  if len(sp.sp_pwdp) == 0:
+    logExceptions('Password for unix user %s is empty' % (sp.sp_namp))
 
 printExceptions()