Merge ~jfguedez/charm-nrpe:cis-hardening-check into charm-nrpe:master

Proposed by Jose Guedez
Status: Merged
Approved by: James Troup
Approved revision: 628764521cece73b165203c99ce0f5d7a30fb439
Merged at revision: 270a31d7643f914c47bb32007368615a715567b3
Proposed branch: ~jfguedez/charm-nrpe:cis-hardening-check
Merge into: charm-nrpe:master
Diff against target: 938 lines (+683/-97)
15 files modified
.coveragerc (+1/-0)
config.yaml (+25/-1)
dev/null (+0/-84)
files/plugins/check_cis_audit.py (+167/-0)
files/plugins/cron_cis_audit.py (+138/-0)
files/plugins/nagios_plugin3.py (+1/-0)
hooks/nrpe_helpers.py (+14/-1)
hooks/nrpe_utils.py (+25/-7)
hooks/services.py (+1/-0)
mod/charmhelpers (+1/-1)
pytest.ini (+4/-0)
tests/functional/tests/nrpe_tests.py (+29/-0)
tests/unit/requirements.txt (+2/-0)
tests/unit/test_plugins_cis_audit.py (+273/-0)
tox.ini (+2/-3)
Reviewer Review Type Date Requested Status
🤖 prod-jenkaas-bootstack (community) continuous-integration Approve
BootStack Reviewers Pending
BootStack Reviewers Pending
Review via email: mp+415840@code.launchpad.net

Commit message

Add cis-audit check and cron job

Description of the change

This change allows the monitoring of cis-hardened systems by running a cis-audit via cron job and verifying the results of the audit via check_cis_audit.py.

Depends on usg-cisbenchmark to be installed (Ubuntu Advantage), will alert if it is not installed.

supersedes https://code.launchpad.net/~stephanpampel/charm-nrpe/+git/charm-nrpe/+merge/414231

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
James Troup (elmo) wrote :

Thanks for picking this work up - see comments inline.

Revision history for this message
Jose Guedez (jfguedez) wrote :

Thanks for the review. I have addressed the issues and pushed the changes. Replies inline.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
Jose Guedez (jfguedez) wrote :

A different MP was merged overnight, causing a conflict in tox.ini. Rebased to master and pushed again

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote :
review: Approve (continuous-integration)
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision 270a31d7643f914c47bb32007368615a715567b3

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/.coveragerc b/.coveragerc
index 3d6d8f3..7ead038 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -4,3 +4,4 @@ exclude_lines =
4 if __name__ == .__main__.:4 if __name__ == .__main__.:
5include=5include=
6 hooks/nrpe_*6 hooks/nrpe_*
7 files/plugins/*
diff --git a/config.yaml b/config.yaml
index a7cb687..4494993 100644
--- a/config.yaml
+++ b/config.yaml
@@ -31,7 +31,7 @@ options:
31 Determines whether a server is identified by its unit name or31 Determines whether a server is identified by its unit name or
32 host name. If you're in a virtual environment, "unit" is32 host name. If you're in a virtual environment, "unit" is
33 probably best. If you're using MaaS, you may prefer "host".33 probably best. If you're using MaaS, you may prefer "host".
34 Use "auto" to have nrpe automatically distinguish between 34 Use "auto" to have nrpe automatically distinguish between
35 metal and non-metal hosts.35 metal and non-metal hosts.
36 dont_blame_nrpe:36 dont_blame_nrpe:
37 default: False37 default: False
@@ -229,6 +229,30 @@ options:
229 'ondemand', 'performance', 'powersave'. Unset value means the check will be disabled.229 'ondemand', 'performance', 'powersave'. Unset value means the check will be disabled.
230 There is a relation key called requested_cpu_governor='string', but the charm config value230 There is a relation key called requested_cpu_governor='string', but the charm config value
231 will take precedence over the relation data.231 will take precedence over the relation data.
232 cis_audit_enabled:
233 default: False
234 type: boolean
235 description: |
236 Enabled cis-audit cron job which periodically runs cis-audit and enables check_cis_audit.py
237 which verifies that cis-audit was run recently with an acceptable score (see cis_audit_score)
238 and optionally a specific profile (cis_audit_profile).
239 cis_audit_score:
240 default: "-w 85 -c 80"
241 type: string
242 description: |
243 CIS audit score threshold for alerts. Per default it only checks if hardening was run and if
244 usg-cisbenchmark is installed and reports the score. To enable alerts base on the score this
245 config option can be set.
246 example: -w 85 -c 80
247 cis_audit_profile:
248 default: ""
249 type: string
250 description: |
251 Verify that a specific cis audit profile was used for the audit. If not specified the
252 profile will be extracted from '/var/log/cloud-init-output.log', fallback is 'level1_server'.
253 Options are: '' (disable profile check), 'level1_server', 'level2_server',
254 'level1_workstation' or 'level2_workstation'
255 See also https://ubuntu.com/security/certifications/docs/cis-audit
232 reboot:256 reboot:
233 default: True257 default: True
234 type: boolean258 type: boolean
diff --git a/files/nagios_plugin.py b/files/nagios_plugin.py
235deleted file mode 100644259deleted file mode 100644
index d5a1ff2..0000000
--- a/files/nagios_plugin.py
+++ /dev/null
@@ -1,84 +0,0 @@
1#!/usr/bin/env python
2"""Nagios plugin for python2.7."""
3# Copyright (C) 2005, 2006, 2007, 2012 James Troup <james.troup@canonical.com>
4
5import os
6import stat
7import time
8import traceback
9import sys
10
11
12################################################################################
13
14
15class CriticalError(Exception):
16 """This indicates a critical error."""
17
18 pass
19
20
21class WarnError(Exception):
22 """This indicates a warning condition."""
23
24 pass
25
26
27class UnknownError(Exception):
28 """This indicates a unknown error was encountered."""
29
30 pass
31
32
33def try_check(function, *args, **kwargs):
34 """Perform a check with error/warn/unknown handling."""
35 try:
36 function(*args, **kwargs)
37 except UnknownError, msg: # noqa: E999
38 print msg
39 sys.exit(3)
40 except CriticalError, msg: # noqa: E999
41 print msg
42 sys.exit(2)
43 except WarnError, msg: # noqa: E999
44 print msg
45 sys.exit(1)
46 except: # noqa: E722
47 print "%s raised unknown exception '%s'" % (function, sys.exc_info()[0])
48 print "=" * 60
49 traceback.print_exc(file=sys.stdout)
50 print "=" * 60
51 sys.exit(3)
52
53
54################################################################################
55
56
57def check_file_freshness(filename, newer_than=600):
58 """Check a file.
59
60 It check that file exists, is readable and is newer than <n> seconds (where
61 <n> defaults to 600).
62 """
63 # First check the file exists and is readable
64 if not os.path.exists(filename):
65 raise CriticalError("%s: does not exist." % (filename))
66 if os.access(filename, os.R_OK) == 0:
67 raise CriticalError("%s: is not readable." % (filename))
68
69 # Then ensure the file is up-to-date enough
70 mtime = os.stat(filename)[stat.ST_MTIME]
71 last_modified = time.time() - mtime
72 if last_modified > newer_than:
73 raise CriticalError(
74 "%s: was last modified on %s and is too old (> %s seconds)."
75 % (filename, time.ctime(mtime), newer_than)
76 )
77 if last_modified < 0:
78 raise CriticalError(
79 "%s: was last modified on %s which is in the future."
80 % (filename, time.ctime(mtime))
81 )
82
83
84################################################################################
diff --git a/files/plugins/check_cis_audit.py b/files/plugins/check_cis_audit.py
85new file mode 1007550new file mode 100755
index 0000000..47c57d2
--- /dev/null
+++ b/files/plugins/check_cis_audit.py
@@ -0,0 +1,167 @@
1#!/usr/bin/env python3
2"""
3Check CIS audit score and verify the age of the last report.
4
5This check relies on a cron job that runs 'cis-audit' periodically.
6'cis-audit' is part of the 'usg-cisbenchmark' package that is available to
7Ubuntu Advantage customers.
8
9Example: check_cis_audit.py -p level2_server -a 170 -w 85 -c 80
10"""
11
12
13import argparse
14import glob
15import os
16import sys
17import time
18import xml.etree.ElementTree as ElementTree
19
20
21from nagios_plugin3 import (
22 CriticalError,
23 WarnError,
24 try_check,
25)
26
27
28AUDIT_FOLDER = "/usr/share/ubuntu-scap-security-guides"
29AUDIT_RESULT_GLOB = AUDIT_FOLDER + "/cis-*-results.xml"
30
31
32def get_audit_result_filepath():
33 """Get the path of the newest audit results file."""
34 audit_files = glob.glob(AUDIT_RESULT_GLOB)
35 if not audit_files:
36 msg = (
37 "CRITICAL: Could not find audit results file '{}', "
38 "make sure package usg-cisbenchmark is installed and cis-audit "
39 "cron job is running"
40 ).format(AUDIT_RESULT_GLOB)
41 raise CriticalError(msg)
42 # get newest results file if there are multiple (e.g. after upgrade)
43 return sorted(audit_files, key=os.path.getmtime).pop()
44
45
46def check_file_max_age(max_age, results_filepath):
47 """Verify the age of the file against the max_age parameter."""
48 age_hours = (time.time() - os.path.getmtime(results_filepath)) / 3600
49 if age_hours > max_age:
50 msg = (
51 "CRITICAL: The audit result file age {:.2f}h is older than threshold {}h "
52 "for '{}', make sure the cis-audit cronjob is working"
53 ).format(age_hours, max_age, results_filepath)
54 raise CriticalError(msg)
55
56
57def parse_profile_idref(profile_idref):
58 """Parse the profile idref and return cis-audit level."""
59 profiles = { # name: match_string
60 "level1_server": "cis_profile_Level_1_Server",
61 "level2_server": "cis_profile_Level_2_Server",
62 "level1_workstation": "cis_profile_Level_1_Workstation",
63 "level2_workstation": "cis_profile_Level_2_Workstation",
64 }
65 for profile in profiles:
66 if profile_idref.endswith(profiles[profile]):
67 return profile
68
69 msg = "CRITICAL: could not determine profile from idref '{}'"
70 raise CriticalError(msg.format(profile_idref))
71
72
73def get_audit_score_and_profile(results_filepath):
74 """Extract audit score and profile level from results xml file."""
75 try:
76 root = ElementTree.parse(results_filepath).getroot()
77 namespace = root.tag.split("Benchmark")[0]
78 score = root.find(namespace + "TestResult/" + namespace + "score").text
79 profile_xml = root.find(namespace + "TestResult/" + namespace + "profile")
80 profile = parse_profile_idref(profile_xml.attrib["idref"])
81 except ElementTree.ParseError as parse_error:
82 msg = "CRITICAL: Could not parse audit results file '{}': '{}'"
83 raise CriticalError(msg.format(results_filepath, parse_error))
84 except PermissionError as permission_error:
85 msg = "CRITICAL: Could not read audit results file '{}': {}"
86 raise CriticalError(msg.format(results_filepath, permission_error))
87 return float(score), profile
88
89
90def check_cis_audit(target_profile, max_age, warning, critical):
91 """Check if recent audit report exists and score and level are as specified."""
92 results_filepath = get_audit_result_filepath()
93 check_file_max_age(max_age, results_filepath)
94 score, profile = get_audit_score_and_profile(results_filepath)
95
96 msg = "{}: cis-audit score is {:.2f} of 100; threshold -c {} -w {} ({}; {})"
97 if score < critical:
98 raise CriticalError(
99 msg.format("CRITICAL", score, critical, warning, profile, results_filepath)
100 )
101 if score < warning:
102 raise WarnError(
103 msg.format("WARNING", score, critical, warning, profile, results_filepath)
104 )
105
106 if target_profile != "" and target_profile != profile:
107 msg = (
108 "CRITICAL: requested audit profile '{}' does not match "
109 "report profile '{}' from '{}'"
110 ).format(target_profile, profile, results_filepath)
111 raise CriticalError(msg)
112
113 print("OK: cis-audit score is {:.2f} of 100 (profile: {})".format(score, profile))
114
115
116def parse_args(args):
117 """Parse command-line options."""
118 parser = argparse.ArgumentParser(
119 prog=__file__,
120 description="Check CIS audit score",
121 )
122 parser.add_argument(
123 "--max-age",
124 "-a",
125 type=int,
126 help="maximum age (h) of audit result file before alerting (default 170)",
127 default=170,
128 )
129 parser.add_argument(
130 "--cis-profile",
131 "-p",
132 choices=[
133 "",
134 "level1_server",
135 "level2_server",
136 "level1_workstation",
137 "level2_workstation",
138 ],
139 help="cis-audit level parameter (verifies if audit report matches)",
140 default="",
141 )
142 parser.add_argument(
143 "--warn",
144 "-w",
145 type=int,
146 help="a score below this number results in status WARNING (default: -1)",
147 default=-1,
148 )
149 parser.add_argument(
150 "--crit",
151 "-c",
152 type=int,
153 help="a score below this number results in status CRITICAL (default: -1)",
154 default=-1,
155 )
156 args = parser.parse_args(args)
157 return args
158
159
160def main():
161 """Parse args and check the audit report."""
162 args = parse_args(sys.argv[1:])
163 try_check(check_cis_audit, args.cis_profile, args.max_age, args.warn, args.crit)
164
165
166if __name__ == "__main__":
167 main()
diff --git a/files/plugins/cron_cis_audit.py b/files/plugins/cron_cis_audit.py
0new file mode 100755168new file mode 100755
index 0000000..706337b
--- /dev/null
+++ b/files/plugins/cron_cis_audit.py
@@ -0,0 +1,138 @@
1#!/usr/bin/python3
2"""Run cis-audit if latest results are outdated."""
3
4import argparse
5import glob
6import grp
7import os
8import random
9import re
10import subprocess
11import sys
12import time
13
14
15AUDIT_FOLDER = "/usr/share/ubuntu-scap-security-guides"
16AUDIT_RESULT_GLOB = AUDIT_FOLDER + "/cis-*-results.xml"
17CLOUD_INIT_LOG = "/var/log/cloud-init-output.log"
18DEFAULT_PROFILE = "level1_server"
19PROFILES = [
20 "level1_server",
21 "level2_server",
22 "level1_workstation",
23 "level2_workstation",
24]
25MAX_SLEEP = 600
26PID_FILENAME = "/tmp/cron_cis_audit.pid"
27
28
29def _get_cis_hardening_profile(profile):
30 """Try to read the cis profile from cloud init log or default to level1_server."""
31 if profile in PROFILES:
32 return profile
33
34 if not os.path.exists(CLOUD_INIT_LOG) or not os.access(CLOUD_INIT_LOG, os.R_OK):
35 print(
36 "{} not existing/accessible, default to profile '{}'".format(
37 CLOUD_INIT_LOG, DEFAULT_PROFILE
38 )
39 )
40 return DEFAULT_PROFILE
41 pattern = re.compile(r"Applying Level-(1|2) scored (server|workstation)")
42 for _, line in enumerate(open(CLOUD_INIT_LOG)):
43 for match in re.finditer(pattern, line):
44 level, machine_type = match.groups()
45 return "level{}_{}".format(level, machine_type)
46 return DEFAULT_PROFILE
47
48
49def _get_cis_result_age():
50 """Get the age of the newest audit results file."""
51 audit_files = glob.glob(AUDIT_RESULT_GLOB)
52 if not audit_files:
53 return False
54 if len(audit_files) >= 1:
55 audit_file = sorted(audit_files, key=os.path.getmtime).pop()
56 return (time.time() - os.path.getmtime(audit_file)) / 3600
57
58
59def run_audit(profile):
60 """Execute the cis-audit as subprocess and allow nagios group to read result."""
61 cmd_run_audit = ["/usr/sbin/cis-audit", profile]
62 sleep_time = random.randint(0, MAX_SLEEP)
63 print("Sleeping for {}s to randomize the cis-audit start time".format(sleep_time))
64 time.sleep(sleep_time)
65 try:
66 print("Run cis-audit: {}".format(cmd_run_audit), flush=True)
67 subprocess.run(
68 cmd_run_audit, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
69 )
70 print("Done cis-audit, change group of result file to nagios", flush=True)
71 for file in glob.glob(AUDIT_RESULT_GLOB):
72 os.chown(file, 0, grp.getgrnam("nagios").gr_gid)
73 except subprocess.CalledProcessError as e:
74 sys.exit(
75 "Failed running command '{}' Return Code {}: {}".format(
76 cmd_run_audit, e.returncode, e.output
77 )
78 )
79
80
81def parse_args(args):
82 """Parse command-line options."""
83 parser = argparse.ArgumentParser(
84 prog=__file__,
85 description="Run cis-audit if report is outdated",
86 )
87 parser.add_argument(
88 "--max-age",
89 "-a",
90 type=int,
91 help="maximum age (h) of audit result file before alerting (default 170)",
92 default=170,
93 )
94 profile_options = PROFILES + [""]
95 parser.add_argument(
96 "--cis-profile",
97 "-p",
98 choices=profile_options,
99 default="",
100 type=str,
101 help="cis-audit level parameter (verifies if audit report matches)",
102 )
103 return parser.parse_args(args)
104
105
106def main():
107 """Run cis-audit if audit results are outdated."""
108 args = parse_args(sys.argv[1:])
109
110 # folder does not exist - usg-cisbenchmark likely not installed
111 if not os.path.exists(AUDIT_FOLDER):
112 raise FileNotFoundError(
113 "Folder {} does not exist, is usg-cisbenchmark installed?".format(
114 AUDIT_FOLDER
115 )
116 )
117
118 # Ensure a single instance via a simple pidfile
119 pid = str(os.getpid())
120
121 if os.path.isfile(PID_FILENAME):
122 sys.exit("{} already exists, exiting".format(PID_FILENAME))
123
124 with open(PID_FILENAME, "w") as f:
125 f.write(pid)
126
127 try:
128 # audit result file does not exist or is outdated
129 audit_file_age_hours = _get_cis_result_age()
130 if audit_file_age_hours is False or audit_file_age_hours > args.max_age:
131 profile = _get_cis_hardening_profile(args.cis_profile)
132 run_audit(profile)
133 finally:
134 os.unlink(PID_FILENAME)
135
136
137if __name__ == "__main__":
138 main()
diff --git a/files/plugins/nagios_plugin3.py b/files/plugins/nagios_plugin3.py
0new file mode 120000139new file mode 120000
index 0000000..045dad3
--- /dev/null
+++ b/files/plugins/nagios_plugin3.py
@@ -0,0 +1 @@
1../nagios_plugin3.py
0\ No newline at end of file2\ No newline at end of file
diff --git a/hooks/nrpe_helpers.py b/hooks/nrpe_helpers.py
index b5631b6..6887069 100644
--- a/hooks/nrpe_helpers.py
+++ b/hooks/nrpe_helpers.py
@@ -443,7 +443,7 @@ class NRPECheckCtxt(dict):
443class SubordinateCheckDefinitions(dict):443class SubordinateCheckDefinitions(dict):
444 """Return dict of checks the charm configures."""444 """Return dict of checks the charm configures."""
445445
446 def __init__(self):446 def __init__(self): # noqa: C901
447 """Set dict values."""447 """Set dict values."""
448 self.procs = self.proc_count()448 self.procs = self.proc_count()
449 load_thresholds = self._get_load_thresholds()449 load_thresholds = self._get_load_thresholds()
@@ -483,6 +483,19 @@ class SubordinateCheckDefinitions(dict):
483 },483 },
484 ]484 ]
485485
486 if hookenv.config("cis_audit_enabled"):
487 cmd_params = "-p '{}' {}".format(
488 hookenv.config("cis_audit_profile"),
489 hookenv.config("cis_audit_score"),
490 )
491 cis_audit_check = {
492 "description": "Check CIS audit",
493 "cmd_name": "check_cis_audit",
494 "cmd_exec": local_plugin_dir + "check_cis_audit.py",
495 "cmd_params": cmd_params,
496 }
497 checks.append(cis_audit_check)
498
486 if not is_container():499 if not is_container():
487 checks.extend(500 checks.extend(
488 [501 [
diff --git a/hooks/nrpe_utils.py b/hooks/nrpe_utils.py
index bc342b0..14ce194 100644
--- a/hooks/nrpe_utils.py
+++ b/hooks/nrpe_utils.py
@@ -91,12 +91,9 @@ def install_charm_files(service_name):
91 charm_plugin_dir = os.path.join(charm_file_dir, "plugins")91 charm_plugin_dir = os.path.join(charm_file_dir, "plugins")
92 pkg_plugin_dir = "/usr/lib/nagios/plugins/"92 pkg_plugin_dir = "/usr/lib/nagios/plugins/"
93 local_plugin_dir = "/usr/local/lib/nagios/plugins/"93 local_plugin_dir = "/usr/local/lib/nagios/plugins/"
94 nagios_plugin = "nagios_plugin3.py"
9495
95 shutil.copy2(96 shutil.copy2(
96 os.path.join(charm_file_dir, "nagios_plugin.py"),
97 pkg_plugin_dir + "/nagios_plugin.py",
98 )
99 shutil.copy2(
100 os.path.join(charm_file_dir, "nagios_plugin3.py"),97 os.path.join(charm_file_dir, "nagios_plugin3.py"),
101 pkg_plugin_dir + "/nagios_plugin3.py",98 pkg_plugin_dir + "/nagios_plugin3.py",
102 )99 )
@@ -104,9 +101,12 @@ def install_charm_files(service_name):
104 shutil.copy2(os.path.join(charm_file_dir, "rsyncd.conf"), "/etc/rsyncd.conf")101 shutil.copy2(os.path.join(charm_file_dir, "rsyncd.conf"), "/etc/rsyncd.conf")
105 host.mkdir("/etc/rsync-juju.d", perms=0o755)102 host.mkdir("/etc/rsync-juju.d", perms=0o755)
106 host.rsync(charm_plugin_dir, "/usr/local/lib/nagios/", options=["--executability"])103 host.rsync(charm_plugin_dir, "/usr/local/lib/nagios/", options=["--executability"])
107 for nagios_plugin in ("nagios_plugin.py", "nagios_plugin3.py"):104
108 if not os.path.exists(local_plugin_dir + nagios_plugin):105 if not os.path.exists(local_plugin_dir + nagios_plugin):
109 os.symlink(pkg_plugin_dir + nagios_plugin, local_plugin_dir + nagios_plugin)106 os.symlink(
107 os.path.join(pkg_plugin_dir, nagios_plugin),
108 os.path.join(local_plugin_dir, nagios_plugin),
109 )
110110
111111
112def render_nrpe_check_config(checkctxt):112def render_nrpe_check_config(checkctxt):
@@ -202,6 +202,24 @@ def has_consumer():
202 )202 )
203203
204204
205def update_cis_audit_cronjob(service_name):
206 """Install/Remove the cis-audit cron job."""
207 crond_file = "/etc/cron.d/cis-audit"
208
209 if not hookenv.config("cis_audit_enabled"):
210 if os.path.exists(crond_file):
211 os.remove(crond_file)
212 hookenv.log("Cronjob removed at {}".format(crond_file), hookenv.DEBUG)
213 return
214
215 file = "/usr/local/lib/nagios/plugins/cron_cis_audit.py"
216 profile = hookenv.config("cis_audit_profile")
217 cronjob = "*/10 * * * * root ({} -p '{}') 2>&1 | logger -t {}\n"
218 with open(crond_file, "w") as crond_fd:
219 crond_fd.write(cronjob.format(file, profile, "cron_cis_audit"))
220 hookenv.log("Cronjob configured at {}".format(crond_file), hookenv.DEBUG)
221
222
205class TolerantPortManagerCallback(PortManagerCallback):223class TolerantPortManagerCallback(PortManagerCallback):
206 """Manage unit ports.224 """Manage unit ports.
207225
diff --git a/hooks/services.py b/hooks/services.py
index df360bd..34b9887 100644
--- a/hooks/services.py
+++ b/hooks/services.py
@@ -37,6 +37,7 @@ manager = ServiceManager(
37 nrpe_utils.update_monitor_relation,37 nrpe_utils.update_monitor_relation,
38 nrpe_utils.create_host_export_fragment,38 nrpe_utils.create_host_export_fragment,
39 nrpe_utils.render_nrped_files,39 nrpe_utils.render_nrped_files,
40 nrpe_utils.update_cis_audit_cronjob,
40 helpers.render_template(41 helpers.render_template(
41 source="nrpe.tmpl", target="/etc/nagios/nrpe.cfg"42 source="nrpe.tmpl", target="/etc/nagios/nrpe.cfg"
42 ),43 ),
diff --git a/mod/charmhelpers b/mod/charmhelpers
index b53f741..446cbfd 160000
--- a/mod/charmhelpers
+++ b/mod/charmhelpers
@@ -1 +1 @@
1Subproject commit b53f741d1c6f34f26f889d79afaad838dc14fdfa1Subproject commit 446cbfdad83e15b5cfd20f862d3c3b5b1956b998
diff --git a/pytest.ini b/pytest.ini
2new file mode 1006442new file mode 100644
index 0000000..d347a54
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
1[pytest]
2# Ignore: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3, and in 3.10 it will stop working
3filterwarnings =
4 ignore::DeprecationWarning
diff --git a/tests/functional/tests/nrpe_tests.py b/tests/functional/tests/nrpe_tests.py
index 1b626a6..53fb6b9 100644
--- a/tests/functional/tests/nrpe_tests.py
+++ b/tests/functional/tests/nrpe_tests.py
@@ -281,6 +281,29 @@ class TestNrpe(TestBase):
281 self._get_set_comparison(expected_host_only_checks, container_checks),281 self._get_set_comparison(expected_host_only_checks, container_checks),
282 )282 )
283283
284 def test_07_cronjob_checks(self):
285 """Check that cron job is installed and check enabled."""
286 model.set_application_config(
287 self.application_name,
288 {
289 "cis_audit_enabled": "True",
290 },
291 )
292 model.block_until_all_units_idle()
293 host_checks = self._get_unit_check_files("rabbitmq-server/0")
294 expected_shared_checks = set(["check_cis_audit.cfg"])
295 self.assertTrue(
296 expected_shared_checks.issubset(host_checks),
297 self._get_set_comparison(expected_shared_checks, host_checks),
298 )
299
300 cronjobs = self._get_cronjob_files("rabbitmq-server/0")
301 expected_cronjobs = set(["cis-audit"])
302 self.assertTrue(
303 expected_cronjobs.issubset(cronjobs),
304 self._get_set_comparison(expected_cronjobs, cronjobs),
305 )
306
284 def _get_unit_check_files(self, unit):307 def _get_unit_check_files(self, unit):
285 cmdline = "ls /etc/nagios/nrpe.d/"308 cmdline = "ls /etc/nagios/nrpe.d/"
286 result = model.run_on_unit(unit, cmdline)309 result = model.run_on_unit(unit, cmdline)
@@ -295,3 +318,9 @@ class TestNrpe(TestBase):
295 "Actual:": actual_checks,318 "Actual:": actual_checks,
296 }319 }
297 )320 )
321
322 def _get_cronjob_files(self, unit):
323 cmdline = "ls /etc/cron.d/"
324 result = model.run_on_unit(unit, cmdline)
325 self.assertEqual(result["Code"], "0")
326 return set(result["Stdout"].splitlines())
diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
index 9d85006..9ddb28d 100644
--- a/tests/unit/requirements.txt
+++ b/tests/unit/requirements.txt
@@ -2,3 +2,5 @@ coverage
2six2six
3PyYAML3PyYAML
4netifaces4netifaces
5pytest
6pytest-cov
diff --git a/tests/unit/test_plugins_cis_audit.py b/tests/unit/test_plugins_cis_audit.py
5new file mode 1006447new file mode 100644
index 0000000..e9a9319
--- /dev/null
+++ b/tests/unit/test_plugins_cis_audit.py
@@ -0,0 +1,273 @@
1"""Unit tests for files/plugins/(cron_cis_audit.py|check_cis_audit.py) module."""
2
3import argparse
4import os
5import tempfile
6from io import StringIO
7from time import sleep
8from unittest import TestCase, mock
9
10from files.plugins import check_cis_audit, cron_cis_audit
11
12from nagios_plugin3 import CriticalError, WarnError
13
14DUMMY_LOGLINES = """
15Processing triggers for man-db (2.8.3-2ubuntu0.1) ...
16Processing triggers for libc-bin (2.27-3ubuntu1.4) ...
17***Applying Level-2 scored server remediation for failures on a fresh Ubuntu 18.04 install***
18""" # noqa: E501
19
20DUMMY_AUDIT_RESULT = """<?xml version="1.0" encoding="UTF-8"?>
21<Benchmark xmlns="http://checklists.nist.gov/xccdf/1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" id="xccdf_com.ubuntu.bionic.cis_benchmark_CIS" resolved="1" xml:lang="en" style="SCAP_1.2">
22 <TestResult id="xccdf_org.open-scap_testresult_xccdf_com.ubuntu.bionic.cis_profile_Level_1_Server" start-time="2022-01-13T10:40:20" end-time="2022-01-13T10:40:45" version="2.0.1" test-system="cpe:/a:redhat:openscap:1.2.15">
23 <profile idref="xccdf_com.ubuntu.bionic.cis_profile_Level_1_Server"/>
24 <score system="urn:xccdf:scoring:default" maximum="100.000000">89.444443</score>
25 </TestResult>
26</Benchmark>
27""" # noqa: E501
28
29
30class TestCronCisAudit(TestCase):
31 """Test the cis-audit cron job functions."""
32
33 cloud_init_logfile = os.path.join(tempfile.gettempdir(), "cloud-init-output.log")
34
35 @classmethod
36 def setUpClass(cls):
37 """Create dummy log file."""
38 with open(cls.cloud_init_logfile, "w") as f:
39 f.write(DUMMY_LOGLINES)
40
41 @classmethod
42 def tearDownClass(cls):
43 """Delete dummy log file."""
44 if os.path.exists(cls.cloud_init_logfile):
45 os.remove(cls.cloud_init_logfile)
46
47 def test_get_cis_hardening_profile_default(self):
48 """Test hardening profile passing defaults."""
49 # default profile should be return if profile passed is invalid
50 profile = cron_cis_audit._get_cis_hardening_profile("")
51 self.assertEqual(
52 profile,
53 cron_cis_audit.DEFAULT_PROFILE,
54 "Default profile should have been returned",
55 )
56 # parameter should be returned if parameter contains a valid profile
57 expected_profile = cron_cis_audit.PROFILES[3]
58 profile = cron_cis_audit._get_cis_hardening_profile(expected_profile)
59 self.assertEqual(
60 profile,
61 expected_profile,
62 "The profile in the parameter should have been returned",
63 )
64
65 @mock.patch("files.plugins.cron_cis_audit.CLOUD_INIT_LOG", cloud_init_logfile)
66 def test_get_cis_hardening_profile_cloudinit(self):
67 """Test the detection of the hardening profile from cloudinit.log."""
68 expected_profile = "level2_server"
69 profile = cron_cis_audit._get_cis_hardening_profile("")
70 self.assertEqual(
71 profile,
72 expected_profile,
73 "Profile from Dummy file should be 'level2_server'",
74 )
75
76 def test_get_cis_result_age(self):
77 """Test file age function."""
78 # file does not exist, returns false
79 self.assertFalse(cron_cis_audit._get_cis_result_age())
80
81 # file was created when test initiated, should return 0
82 with mock.patch(
83 "files.plugins.cron_cis_audit.AUDIT_RESULT_GLOB", self.cloud_init_logfile
84 ):
85 age_in_hours = cron_cis_audit._get_cis_result_age()
86 self.assertLess(
87 age_in_hours,
88 0.1,
89 "File age should be small because the file was just created",
90 )
91
92 @mock.patch("sys.stderr", new_callable=StringIO)
93 def test_parse_args(self, mock_stderr):
94 """Test the default parsing behavior of the argument parser."""
95 # test empty parameters
96 args = cron_cis_audit.parse_args([])
97 self.assertEqual(args, argparse.Namespace(cis_profile="", max_age=170))
98
99 # test setting parameters
100 args = cron_cis_audit.parse_args(["-a 1", "-p=level2_workstation"])
101 self.assertEqual(
102 args, argparse.Namespace(cis_profile="level2_workstation", max_age=1)
103 )
104
105 # test setting invalid parameter
106 with self.assertRaises(SystemExit):
107 cron_cis_audit.parse_args(["-p=invalid-parameter-test"])
108 self.assertRegex(
109 mock_stderr.getvalue(), r"invalid choice: 'invalid-parameter-test'"
110 )
111
112 @mock.patch("sys.argv", [])
113 def test_main_raise_exception(self):
114 """Test if main() raises FileNotFoundError if AUDIT_FOLDER does not exist."""
115 with self.assertRaises(FileNotFoundError):
116 cron_cis_audit.main()
117
118 @mock.patch("files.plugins.cron_cis_audit.MAX_SLEEP", 1)
119 @mock.patch("files.plugins.cron_cis_audit.AUDIT_FOLDER", "/tmp")
120 @mock.patch("sys.argv", [])
121 def test_main_run_audit(self):
122 """Test if main() calles cis-audit is called with correct arguments."""
123 with mock.patch("subprocess.run") as mock_subprocess_run:
124 process_mock = mock.Mock()
125 attrs = {"communicate.return_value": ("output", "error")}
126 process_mock.configure_mock(**attrs)
127 mock_subprocess_run.return_value = process_mock
128 cron_cis_audit.main()
129 self.assertTrue(mock_subprocess_run.called)
130 self.assertEqual(
131 str(mock_subprocess_run.call_args),
132 "call(['/usr/sbin/cis-audit', 'level1_server'], stdout=-3, stderr=-3)",
133 )
134
135
136class TestCheckCisAudit(TestCase):
137 """Test the cis-audit cron job functions."""
138
139 audit_result_folder = os.path.join(tempfile.gettempdir(), "test-audit-result")
140 audit_results_glob = audit_result_folder + "/cis-*-results.xml"
141 testfile1 = os.path.join(audit_result_folder, "cis-testfile1-results.xml")
142 testfile2 = os.path.join(audit_result_folder, "cis-testfile2-results.xml")
143
144 @classmethod
145 def setUpClass(cls):
146 """Create dummy audit folder and files."""
147 if not os.path.exists(cls.audit_result_folder):
148 os.mkdir(cls.audit_result_folder)
149 with open(cls.testfile1, mode="a"):
150 pass # create empty file
151 sleep(0.1)
152 with open(cls.testfile2, mode="w") as f:
153 f.write(DUMMY_AUDIT_RESULT)
154
155 @classmethod
156 def tearDownClass(cls):
157 """Delete dummy log file."""
158 if os.path.exists(cls.audit_result_folder):
159 for file in os.listdir(cls.audit_result_folder):
160 os.remove(os.path.join(cls.audit_result_folder, file))
161 os.rmdir(cls.audit_result_folder)
162
163 def test_get_audit_result_filepath_not_found(self):
164 """Test that the audit results file can be found."""
165 with self.assertRaises(CriticalError):
166 check_cis_audit.get_audit_result_filepath()
167
168 @mock.patch("files.plugins.check_cis_audit.AUDIT_RESULT_GLOB", audit_results_glob)
169 def test_get_audit_result_filepath_found(self):
170 """Test that the newest audit file is returned."""
171 audit_result_filepath = check_cis_audit.get_audit_result_filepath()
172 expected = os.path.join(self.audit_result_folder, "cis-testfile2-results.xml")
173 self.assertEqual(audit_result_filepath, expected)
174
175 def test_check_file_max_age(self):
176 """Test that an exception is raised if the file is too old."""
177 with self.assertRaises(CriticalError):
178 check_cis_audit.check_file_max_age(0, self.testfile1)
179
180 def test_parse_profile_idref(self):
181 """Test that profile parsing works correctly."""
182 with self.assertRaises(CriticalError):
183 check_cis_audit.parse_profile_idref("unknown_profile")
184
185 profile_id = "xccdf_com.ubuntu.bionic.cis_profile_Level_2_Workstation"
186 self.assertEqual(
187 "level2_workstation", check_cis_audit.parse_profile_idref(profile_id)
188 )
189
190 def test_get_audit_score_and_profile(self):
191 """Test the parsing of the audit results file."""
192 # empty file raises CriticalError
193 with self.assertRaises(CriticalError):
194 check_cis_audit.get_audit_score_and_profile(self.testfile1)
195
196 # score and profile correctly read from xml
197 score, profile = check_cis_audit.get_audit_score_and_profile(self.testfile2)
198 self.assertEqual(score, 89.444443)
199 self.assertEqual(profile, "level1_server")
200
201 @mock.patch("sys.argv", [])
202 def test_parse_args(self):
203 """Test the argument parsing."""
204 # test default arguments
205 arguments = check_cis_audit.parse_args([])
206 self.assertEqual(
207 arguments,
208 argparse.Namespace(
209 cis_profile="",
210 crit=-1,
211 max_age=170,
212 warn=-1,
213 ),
214 )
215
216 # test setting arguments
217 arguments = check_cis_audit.parse_args(
218 ["-a", "1", "-c", "99", "-w", "90", "-p", "level2_server"]
219 )
220 self.assertEqual(
221 arguments,
222 argparse.Namespace(
223 cis_profile="level2_server",
224 crit=99,
225 max_age=1,
226 warn=90,
227 ),
228 )
229
230 @mock.patch("files.plugins.check_cis_audit.AUDIT_RESULT_GLOB", audit_results_glob)
231 def test_check_cis_audit(self):
232 """Test the check function with different parameters."""
233 # all ok
234 check_cis_audit.check_cis_audit("", 1, 80, 85)
235
236 # too old
237 with self.assertRaises(CriticalError) as error:
238 check_cis_audit.check_cis_audit("", 0, 80, 85)
239 self.assertRegex(
240 str(error.exception),
241 "CRITICAL: The audit result file age 0.00h is older than threshold.*",
242 )
243
244 # score below warning
245 with self.assertRaises(WarnError) as error:
246 check_cis_audit.check_cis_audit("", 1, 90, 80)
247 self.assertRegex(
248 str(error.exception),
249 "WARNING: cis-audit score is 89.44 of 100; threshold -c 80 -w 90",
250 )
251
252 # score below critical
253 with self.assertRaises(CriticalError) as error:
254 check_cis_audit.check_cis_audit("", 1, 95, 90)
255 self.assertRegex(
256 str(error.exception),
257 "CRITICAL: cis-audit score is 89.44 of 100; threshold -c 90 -w 95",
258 )
259
260 # profile does not match
261 with self.assertRaises(CriticalError) as error:
262 check_cis_audit.check_cis_audit("level2_workstation", 1, 85, 80)
263 self.assertRegex(
264 str(error.exception),
265 "CRITICAL: requested audit profile 'level2_workstation' does not match",
266 )
267
268 @mock.patch("files.plugins.check_cis_audit.AUDIT_RESULT_GLOB", audit_results_glob)
269 def test_main(self):
270 """Test the main function."""
271 namespace = argparse.Namespace(cis_profile="", max_age=1, crit=80, warn=70)
272 with mock.patch("argparse.ArgumentParser.parse_args", return_value=namespace):
273 check_cis_audit.main()
diff --git a/tox.ini b/tox.ini
index 096b228..21b6e4b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -64,9 +64,8 @@ deps =
6464
65[testenv:unit]65[testenv:unit]
66commands =66commands =
67 coverage run -m unittest discover -s {toxinidir}/tests/unit -v67 pytest {posargs:-v --cov --cov-config={toxinidir}/.coveragerc --cov-report=term-missing --cov-report html --cov-branch --ignore={toxinidir}/tests/charmhelpers} \
68 coverage report --omit tests/*,mod/*,.tox/*68 {toxinidir}/tests/unit
69 coverage html --omit tests/*,mod/*,.tox/*
70deps = -r{toxinidir}/tests/unit/requirements.txt69deps = -r{toxinidir}/tests/unit/requirements.txt
7170
72[testenv:func]71[testenv:func]

Subscribers

People subscribed via source and target branches

to all changes: