From 286b03de75f92251108e9088a190280ae40dcf8f Mon Sep 17 00:00:00 2001 From: Zbigniew Jędrzejewski-Szmek Date: Nov 05 2017 16:53:23 +0000 Subject: A python script to summarize unit harderning status This is intended to run on the whole distro. --- diff --git a/analyze-protections.py b/analyze-protections.py new file mode 100644 index 0000000..b08516b --- /dev/null +++ b/analyze-protections.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +# -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */ +# +# This file is distrubuted under the MIT license, see below. +# +# Copyright 2017 Zbigniew Jędrzejewski-Szmek +# +# The MIT License (MIT) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import argparse +import collections +import pathlib +import pprint +import re +import subprocess + +def parser(): + p = argparse.ArgumentParser() + p.add_argument('files', type=pathlib.Path, nargs='+') + return p + +def slurp(name, contents): + section = None + config = collections.defaultdict(lambda: collections.defaultdict(list)) + + lines = enumerate(contents.splitlines()) + while True: + logical_line = '' + for line_num, line in lines: + line = line.strip() + if line.startswith('#') or not line: + break + if line.endswith('\\'): + logical_line += line + ' ' + continue + else: + logical_line += line + break + else: + break + # we have a full logical line + # print(f'got logical_line {logical_line}') + if not logical_line: + continue + + m = re.match(r'\[(\w+)\]', line) + if m: + section = m.group(1) + continue + + m = re.match(r'(\w+)=(.*)', line) + if m: + key, value = m.group(1), m.group(2) + config[section][key].append(value) + continue + + raise SyntaxError(f'{name}, line {line_num}: cannot parse "{line}"') + return config + +def get_single_config(config, section, name, default=None): + try: + # -1 because the last setting wins + return config[section][name][-1] + except IndexError: + return default + +def systemd_bool(val): + if val is None or val.lower() in {'no', '0', 'false', 'off'}: + return False + if val.lower() in {'yes', '1', 'true', 'on'}: + return True + raise ValueError(f'invalid bool: "{val}"') + +def non_empty(val): + return val is not None and val != '' + +PROTECT_SETTINGS = ('ProtectControlGroups', + 'ProtectHome', + 'ProtectKernelModules', + 'ProtectKernelTunables', + 'ProtectSystem') + +ACCESS_RESTRICTIONS = (('PrivateDevices', systemd_bool), + ('SystemCallFilter', non_empty), + ('SystemCallArchitectures', non_empty), + ('RestrictNamespaces', non_empty), + ('MemoryDenyWriteExecute', systemd_bool), + ('RestrictRealtime', systemd_bool), + ('CapabilityBoundingSet', non_empty)) + +NETWORK_RESTRICTIONS = (('PrivateNetwork', systemd_bool), + ('RestrictAddressFamilies', non_empty), + ('IPAddressDeny', non_empty)) + +def count_protections(Protections, name, config): + "Check if the unit has any protections at all" + some = False + + user = get_single_config(config, 'Service', 'User', None) + if user and user != 'root': + some = True + Protections['user'] += 1 + print(f' User={user}') + + v = get_single_config(config, 'Service', 'DynamicUser', '0') + dynamic_user = systemd_bool(v) + if dynamic_user: + some = True + Protections['user'] += 1 + Protections['dynamic-user'] += 1 + print(f' DynamicUser={v}') + + v = get_single_config(config, 'Service', 'PrivateUsers', '0') + if systemd_bool(v): + some = True + Protections['private-users'] += 1 + print(f' PrivateUsers={v}') + + v = get_single_config(config, 'Service', 'PrivateTmp', '0') + if systemd_bool(v): + some = True + Protections['private-tmp'] += 1 + print(f' PrivateTmp={v}') + + access_restrictions = False + for protection, check in ACCESS_RESTRICTIONS: + v = get_single_config(config, 'Service', protection, None) + if check(v): + some = access_restrictions = True + print(f' {protection}={v}') + if access_restrictions: + Protections['access-restrictions'] += 1 + + network_restrictions = False + for protection, check in NETWORK_RESTRICTIONS: + v = get_single_config(config, 'Service', protection, None) + if check(v): + some = network_restrictions = True + print(f' {protection}={v}') + if network_restrictions: + Protections['access-restrictions'] += 1 + + protect_settings = False + for protection in PROTECT_SETTINGS: + v = get_single_config(config, 'Service', protection, + '1' if dynamic_user else '0') + if v in {'read-only', 'strict', 'full'} or systemd_bool(v): + some = protect_settings = True + print(f' {protection}={v}') + if protect_settings: + Protections['protect'] += 1 + + if not some: + Protections['none'] += 1 + +def analyze(Types, Protections, name): + print(f'============================== {name} ==============================') + + # We use systemctl cat to include any drop-ins. It is likely that + # this makes no difference (the distribution doesn't include any harderning + # features in drop-ins), but let's do this just in case. + contents = subprocess.check_output(['systemctl', 'cat', name], universal_newlines=True) + + config = slurp(name, contents) + # pprint.pprint(config) + + type = get_single_config(config, 'Service', 'Type', 'simple') + Types[type] += 1 + + if type in {'simple', 'forking', 'dbus', 'notify'}: + count_protections(Protections, name, config) + +if __name__ == '__main__': + opts = parser().parse_args() + + Types = collections.Counter() + Protections = collections.Counter() + for file in opts.files: + if file.is_symlink(): + # an alias, ignore + continue + analyze(Types, Protections, file.name) + + pprint.pprint(Types) + pprint.pprint(Protections)