Mini Shell
Direktori : /bin/ |
|
Current File : //bin/imh-scan |
#!/opt/imh-python/bin/python3
"""clamscan wrapper for scanning accounts for malware"""
from datetime import datetime
from pathlib import Path
import platform
import pwd
import re
import shlex
import sys
import os
from argparse import ArgumentParser, ArgumentTypeError as BadArg
from typing import Union
import rads
import rads.color as c
from clamlib import DUMMY, HOME_RE, CUR_USER_HOME
from clamlib import ScanResult, Scanner, ask_prompt, jail_files
IS_ROOT = os.getuid() == 0
LOGIN_USER = os.environ.get('SUDO_USER', '')
if not LOGIN_USER:
LOGIN_USER = os.environ['USER']
HOME_USER_RE = re.compile(r'^/home[0-9]{0,2}/([a-zA-Z0-9]{1,16})/')
BANNER = """
╦╔╦╗╦ ╦ ╔═╗╔═╗╔═╗╔╗╔
║║║║╠═╣───╚═╗║ ╠═╣║║║
╩╩ ╩╩ ╩ ╚═╝╚═╝╩ ╩╝╚╝
"""
sys.stdout.reconfigure(errors="surrogateescape", line_buffering=True)
sys.stderr.reconfigure(errors="surrogateescape", line_buffering=True)
def print_banner(log_paths: list[Path], scan_paths: list[Path]):
print(c.magenta(BANNER.strip()))
num_logs = len(log_paths)
if num_logs == 0:
print("Log path:", c.cyan('None'))
elif num_logs == 1:
print("Log path:", c.cyan(shlex.quote(str(log_paths[0]))))
else:
print('Log paths:', c.cyan(shlex.join(map(str, log_paths))))
print("Scan paths:", c.cyan(shlex.join(map(str, scan_paths))))
def user_arg(user: str) -> str:
"""Argparse type: checks rads.cpuser_safe"""
if not rads.cpuser_safe(user):
raise BadArg('user does not exist or is restricted')
return user
def dir_arg(str_path: str) -> str:
path = Path(str_path).resolve()
if rads.IMH_ROLE == "shared":
if not HOME_RE.match(f"{path}/"):
raise BadArg("path not contained in a user homedir")
if not path.exists():
raise BadArg("path does not exist")
dir_path = path
if not path.is_dir():
# if pointing to a file, test read on its parent and the file itself
dir_path = path.parent
if not os.access(path, os.R_OK):
raise BadArg(f"no read perms on {path}")
if not os.access(dir_path, os.R_OK):
raise BadArg(f"no read perms on {dir_path}")
if not os.access(dir_path, os.X_OK):
raise BadArg(f"no execute perms on {dir_path}")
return path
def parse_args():
"""argparse function"""
parser = ArgumentParser(description=__doc__)
# fmt: off
targets = parser.add_mutually_exclusive_group()
if IS_ROOT:
targets.add_argument(
'-u', '--user', dest='users', type=user_arg, nargs='*',
help='List of usernames to scan',
)
targets.add_argument(
'-r', '--reseller', dest='reseller', type=user_arg,
const=True, default=None, nargs='?',
help='Reseller to scan along with all of its child accounts',
)
parser.add_argument(
'-U', '--update', action='store_true',
help='Only updates definitions',
)
targets.add_argument(
'-d', '--directory', nargs='+', dest='paths', type=dir_arg,
help='List of directories to scan',
)
parser.add_argument(
'-n', '--no-quarantine',
action="store_const", const=False, dest='auto_quarantine', default=None,
help='Skips quarantine and does not ask',
)
parser.add_argument(
'-q', '--quarantine',
action="store_const", const=True, dest='auto_quarantine',
help='Quarantines automatically and does not ask '
'(does not include items found with heuristics)',
)
email_group = parser.add_mutually_exclusive_group()
email_group.add_argument(
'-e', '--email', nargs='?', const=True, default=None,
help='Email address to send notice of completion',
)
email_group.add_argument(
'-S', action='store_true', dest='shellscan_ticket',
help='Email to shellscan ticket queue. '
'Shorthand for -e shellscan@inmotionhosting.com',
)
parser.add_argument(
'-M', '--disable-media', action='store_true',
help='excludes filenames with common video/image extensions',
)
parser.add_argument(
'-E', '--exclude', type=str, nargs='*', default=[],
help='arbitrary values to exclude',
)
parser.add_argument(
'-D', '--disable-excludes', action='store_true',
help='Doesnt exclude all the cPanel dirs',
)
parser.add_argument(
'-x', '--disable-new-yara', action='store_true',
help=f'disables {DUMMY}',
)
parser.add_argument(
'-H', '--heuristic', action='store_true',
help='Adds false postive prone heuristics scan.\n'
'All heuristics should be verified',
)
parser.add_argument(
'-Z', '--disable-default', action='store_true',
help='Disables the default clamav definitions',
)
parser.add_argument(
'-m', '--disable-maldetect', action='store_true',
help='Disables the maldetect definitions',
)
parser.add_argument(
'-f', '--force', action='store_true',
help='Disables freshclam auto update',
)
parser.add_argument(
'-N', '--disable-freshclam', action='store_true',
help='Disables definition updates for root',
)
parser.add_argument(
'-P', '--phishing', action='store_true',
help='EXPERIMENTAL: enables clamscan flag --phishing-sigs=yes',
)
parser.add_argument(
'-X', '--extra-heuri', action='store_true',
help='EXPERIMENTAL: enables clamscan flag --heuristic-alerts=yes',
)
parser.add_argument(
'-v', '--verbose', action='store_true', help='print debug info'
)
parser.add_argument(
'-i', '--install', action='store_true',
help='installs missing imh-clamav{,-db} without asking',
)
parser.add_argument(
'-L', '--disable-logs', action='store_true',
help='disables logging filesystem, quarantine still works though',
)
parser.add_argument(
'-t', '--ticket', nargs='?', const=True, default=None,
help='appends a ticket number to log file name',
)
# fmt: on
args = parser.parse_args()
if args.ticket is True:
args.ticket = input("Ticket Number: ")
if args.email is True:
args.email = input("Email: ")
elif args.shellscan_ticket:
args.email = 'shellscan@inmotionhosting.com'
if not IS_ROOT:
args.reseller = None
args.users = None
args.update = False
else:
try:
if args.reseller is True:
args.reseller = user_arg(input("WHM/Reseller User: "))
elif args.users == []:
args.users = [user_arg(input("cPanel User: "))]
except BadArg as exc:
sys.exit(exc)
if not any(
(args.users is not None, args.reseller, args.paths, args.update)
):
parser.print_help()
sys.exit(1)
return args
def decide_log_path(
reseller: Union[str, None],
user: Union[str, None],
ticket: Union[str, None],
time_str: str,
) -> list[tuple[Path, Union[pwd.struct_passwd, None]]]:
"""log file decision, also builds scan dir list"""
ret = []
if ticket:
user_log_name = f"scanlog.{time_str}.{ticket}.log"
else:
user_log_name = f"scanlog.{time_str}.log"
if IS_ROOT:
if reseller:
# If a reseller was specified, log to ~reseller/scanlogs.
# The reseller arg is only available if running as root.
pw_res = pwd.getpwnam(reseller)
_verify_homedir(pw_res.pw_dir)
ret.append((Path(pw_res.pw_dir, 'scanlogs', user_log_name), pw_res))
elif user:
# If running as root and a user was specified, log to ~user/scanlogs
pw_usr = pwd.getpwnam(user)
_verify_homedir(pw_usr.pw_dir)
ret.append((Path(pw_usr.pw_dir, 'scanlogs', user_log_name), pw_usr))
elif rads.IMH_ROLE != 'shared':
# Running as root on vps/ded with -d
ret.append((Path('/root/scanlogs', user_log_name), None))
if rads.IMH_ROLE == 'shared':
# If running as root on shared, log to ~t1bin/scanlogs.
# LOGIN_USER is their user prior to sudo.
if ticket:
t1bin_log_name = f"{LOGIN_USER}.{time_str}.{ticket}.log"
else:
t1bin_log_name = f"{LOGIN_USER}.{time_str}.log"
ret.append((Path('/home/t1bin/scanlogs', t1bin_log_name), None))
else: # not running as root
# these args shouldn't be available when not root. quick sanity check.
assert not reseller
assert not user
# log to current user's home
_verify_homedir(CUR_USER_HOME)
ret.append((CUR_USER_HOME / 'scanlogs' / user_log_name, None))
return ret
def _verify_homedir(home: Union[str, Path]):
if not HOME_RE.match(str(home) + '/'):
sys.exit(f"{home} does not match expected homedir pattern")
def get_scan_paths(args) -> list[Path]:
if args.paths:
return args.paths
if args.users:
try:
return [Path(rads.get_homedir(x)).resolve() for x in args.users]
except Exception as exc:
print(exc, file=sys.stderr)
if args.reseller:
users = [args.reseller]
users.extend(rads.get_children(args.reseller))
return [Path(rads.get_homedir(x)).resolve() for x in users]
return []
def send_email(
to_addr: str,
ticket: str,
log_paths: list[Path],
scan_paths: list[Path],
result: ScanResult,
):
"""sends email notice of scan results"""
info = ""
if screen := os.environ.get('STY', ''):
info += f'Screen: {screen}'
if ticket:
info += f'Ticket: {ticket}'
paths = ' '.join(map(str, scan_paths))
log_info = ''
for log_path in log_paths:
log_info = f"{log_info}\nLog path: {log_path}"
if log_paths:
log_info += '\n'
if result.all_found:
detections = 'Detections:\n' + '\n'.join(map(str, result.all_found))
elif result.rcode < 0:
detections = ''
else:
detections = 'Detections:\nNo malware found.'
if result.rcode < 0:
kill_warning = (
f"\n\nScan was interrupted with kill signal {-result.rcode}\n"
)
if result.rcode == -9:
kill_warning += 'This usually means an out-of-memory condition.\n'
else:
kill_warning = ''
message = f"""'
Hostname: {platform.node()}
Running user: {LOGIN_USER}
{info}
Ran command:
{result.command}
{log_info}{kill_warning}
{detections}
"""
print('Sending email to', to_addr)
try:
rads.send_email(
to_addr=to_addr,
subject=f'imh-scan scan results for {paths}',
body=message,
ssl=True,
server=('localhost', 465),
errs=True,
)
except Exception as exc:
print(exc, file=sys.stderr)
def quarantine(
auto_quarantine: Union[bool, None],
log_paths: list[Path],
result: ScanResult,
time_str: str,
):
"""Prechecks to decide if should quarantine"""
if auto_quarantine is False:
return
if auto_quarantine is True:
jail_files(list(result.hits_found.keys()), time_str=time_str)
return
for log_path in log_paths:
print('Scan log path:', log_path)
user_input = ask_prompt(
'Would you like to quarantine the detected files? (y|n|a)\n'
'y = excludes heuristics\n'
'n = no quarantine\n'
'a = quarantines all detections',
chars=('y', 'n', 'a'),
)
if user_input == 'y':
print(c.yellow('Quarantining files'), end='...\n')
jail_files(list(result.hits_found.keys()), time_str=time_str)
elif user_input == 'a':
print(c.yellow('Quarantining files'), end='...\n')
jail_files(list(result.all_found.keys()), time_str=time_str)
def print_infected_users(result: ScanResult):
"""Checks paths to give a list of infected users"""
users = set()
for path in result.all_found:
if match := HOME_USER_RE.match(str(path)):
users.add(match.group(1))
else:
print('Could not detect user from path: %s', path, file=sys.stderr)
if not users:
return
joined = ', '.join(users)
print(c.bold(f'Detected infected users: {joined}'))
def main():
args = parse_args()
time_str = datetime.today().strftime('%Y-%m-%d-%M-%S')
scan_paths = get_scan_paths(args)
if args.disable_logs:
log_tuples = []
else:
if args.users and len(args.users) == 1:
log_user = args.users[0]
else:
log_user = None
log_tuples = decide_log_path(
reseller=args.reseller,
user=log_user,
ticket=args.ticket,
time_str=time_str,
)
scanner = Scanner(
exclude=args.exclude,
verbose=args.verbose,
extra_heuri=args.extra_heuri,
install=args.install,
update=args.update,
heuristic=args.heuristic,
phishing=args.phishing,
disable_media=args.disable_media,
disable_excludes=args.disable_excludes,
disable_default=args.disable_default,
disable_freshclam=args.disable_freshclam,
disable_maldetect=args.disable_maldetect,
disable_new_yara=args.disable_new_yara,
)
if not args.force or not IS_ROOT:
scanner.cpu_wait()
if IS_ROOT:
scanner.update_defs(
disable_freshclam=args.disable_freshclam,
disable_default=args.disable_default,
)
else:
print('Not updating defs because not root')
if not scan_paths:
return
print_banner(log_paths=[x[0] for x in log_tuples], scan_paths=scan_paths)
result: ScanResult = scanner.scan(
scan_paths=scan_paths,
log_tuples=log_tuples,
print_items=True,
)
if args.email:
send_email(
to_addr=args.email,
ticket=args.ticket,
log_paths=[x[0] for x in log_tuples],
scan_paths=scan_paths,
result=result,
)
if not result.all_found:
return
quarantine(
auto_quarantine=args.auto_quarantine,
log_paths=[x[0] for x in log_tuples],
result=result,
time_str=time_str,
)
print_infected_users(result)
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
sys.exit("Killed with KeyboardInterrupt")
Zerion Mini Shell 1.0