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
1diff --git a/.coveragerc b/.coveragerc
2index 3d6d8f3..7ead038 100644
3--- a/.coveragerc
4+++ b/.coveragerc
5@@ -4,3 +4,4 @@ exclude_lines =
6 if __name__ == .__main__.:
7 include=
8 hooks/nrpe_*
9+ files/plugins/*
10diff --git a/config.yaml b/config.yaml
11index a7cb687..4494993 100644
12--- a/config.yaml
13+++ b/config.yaml
14@@ -31,7 +31,7 @@ options:
15 Determines whether a server is identified by its unit name or
16 host name. If you're in a virtual environment, "unit" is
17 probably best. If you're using MaaS, you may prefer "host".
18- Use "auto" to have nrpe automatically distinguish between
19+ Use "auto" to have nrpe automatically distinguish between
20 metal and non-metal hosts.
21 dont_blame_nrpe:
22 default: False
23@@ -229,6 +229,30 @@ options:
24 'ondemand', 'performance', 'powersave'. Unset value means the check will be disabled.
25 There is a relation key called requested_cpu_governor='string', but the charm config value
26 will take precedence over the relation data.
27+ cis_audit_enabled:
28+ default: False
29+ type: boolean
30+ description: |
31+ Enabled cis-audit cron job which periodically runs cis-audit and enables check_cis_audit.py
32+ which verifies that cis-audit was run recently with an acceptable score (see cis_audit_score)
33+ and optionally a specific profile (cis_audit_profile).
34+ cis_audit_score:
35+ default: "-w 85 -c 80"
36+ type: string
37+ description: |
38+ CIS audit score threshold for alerts. Per default it only checks if hardening was run and if
39+ usg-cisbenchmark is installed and reports the score. To enable alerts base on the score this
40+ config option can be set.
41+ example: -w 85 -c 80
42+ cis_audit_profile:
43+ default: ""
44+ type: string
45+ description: |
46+ Verify that a specific cis audit profile was used for the audit. If not specified the
47+ profile will be extracted from '/var/log/cloud-init-output.log', fallback is 'level1_server'.
48+ Options are: '' (disable profile check), 'level1_server', 'level2_server',
49+ 'level1_workstation' or 'level2_workstation'
50+ See also https://ubuntu.com/security/certifications/docs/cis-audit
51 reboot:
52 default: True
53 type: boolean
54diff --git a/files/nagios_plugin.py b/files/nagios_plugin.py
55deleted file mode 100644
56index d5a1ff2..0000000
57--- a/files/nagios_plugin.py
58+++ /dev/null
59@@ -1,84 +0,0 @@
60-#!/usr/bin/env python
61-"""Nagios plugin for python2.7."""
62-# Copyright (C) 2005, 2006, 2007, 2012 James Troup <james.troup@canonical.com>
63-
64-import os
65-import stat
66-import time
67-import traceback
68-import sys
69-
70-
71-################################################################################
72-
73-
74-class CriticalError(Exception):
75- """This indicates a critical error."""
76-
77- pass
78-
79-
80-class WarnError(Exception):
81- """This indicates a warning condition."""
82-
83- pass
84-
85-
86-class UnknownError(Exception):
87- """This indicates a unknown error was encountered."""
88-
89- pass
90-
91-
92-def try_check(function, *args, **kwargs):
93- """Perform a check with error/warn/unknown handling."""
94- try:
95- function(*args, **kwargs)
96- except UnknownError, msg: # noqa: E999
97- print msg
98- sys.exit(3)
99- except CriticalError, msg: # noqa: E999
100- print msg
101- sys.exit(2)
102- except WarnError, msg: # noqa: E999
103- print msg
104- sys.exit(1)
105- except: # noqa: E722
106- print "%s raised unknown exception '%s'" % (function, sys.exc_info()[0])
107- print "=" * 60
108- traceback.print_exc(file=sys.stdout)
109- print "=" * 60
110- sys.exit(3)
111-
112-
113-################################################################################
114-
115-
116-def check_file_freshness(filename, newer_than=600):
117- """Check a file.
118-
119- It check that file exists, is readable and is newer than <n> seconds (where
120- <n> defaults to 600).
121- """
122- # First check the file exists and is readable
123- if not os.path.exists(filename):
124- raise CriticalError("%s: does not exist." % (filename))
125- if os.access(filename, os.R_OK) == 0:
126- raise CriticalError("%s: is not readable." % (filename))
127-
128- # Then ensure the file is up-to-date enough
129- mtime = os.stat(filename)[stat.ST_MTIME]
130- last_modified = time.time() - mtime
131- if last_modified > newer_than:
132- raise CriticalError(
133- "%s: was last modified on %s and is too old (> %s seconds)."
134- % (filename, time.ctime(mtime), newer_than)
135- )
136- if last_modified < 0:
137- raise CriticalError(
138- "%s: was last modified on %s which is in the future."
139- % (filename, time.ctime(mtime))
140- )
141-
142-
143-################################################################################
144diff --git a/files/plugins/check_cis_audit.py b/files/plugins/check_cis_audit.py
145new file mode 100755
146index 0000000..47c57d2
147--- /dev/null
148+++ b/files/plugins/check_cis_audit.py
149@@ -0,0 +1,167 @@
150+#!/usr/bin/env python3
151+"""
152+Check CIS audit score and verify the age of the last report.
153+
154+This check relies on a cron job that runs 'cis-audit' periodically.
155+'cis-audit' is part of the 'usg-cisbenchmark' package that is available to
156+Ubuntu Advantage customers.
157+
158+Example: check_cis_audit.py -p level2_server -a 170 -w 85 -c 80
159+"""
160+
161+
162+import argparse
163+import glob
164+import os
165+import sys
166+import time
167+import xml.etree.ElementTree as ElementTree
168+
169+
170+from nagios_plugin3 import (
171+ CriticalError,
172+ WarnError,
173+ try_check,
174+)
175+
176+
177+AUDIT_FOLDER = "/usr/share/ubuntu-scap-security-guides"
178+AUDIT_RESULT_GLOB = AUDIT_FOLDER + "/cis-*-results.xml"
179+
180+
181+def get_audit_result_filepath():
182+ """Get the path of the newest audit results file."""
183+ audit_files = glob.glob(AUDIT_RESULT_GLOB)
184+ if not audit_files:
185+ msg = (
186+ "CRITICAL: Could not find audit results file '{}', "
187+ "make sure package usg-cisbenchmark is installed and cis-audit "
188+ "cron job is running"
189+ ).format(AUDIT_RESULT_GLOB)
190+ raise CriticalError(msg)
191+ # get newest results file if there are multiple (e.g. after upgrade)
192+ return sorted(audit_files, key=os.path.getmtime).pop()
193+
194+
195+def check_file_max_age(max_age, results_filepath):
196+ """Verify the age of the file against the max_age parameter."""
197+ age_hours = (time.time() - os.path.getmtime(results_filepath)) / 3600
198+ if age_hours > max_age:
199+ msg = (
200+ "CRITICAL: The audit result file age {:.2f}h is older than threshold {}h "
201+ "for '{}', make sure the cis-audit cronjob is working"
202+ ).format(age_hours, max_age, results_filepath)
203+ raise CriticalError(msg)
204+
205+
206+def parse_profile_idref(profile_idref):
207+ """Parse the profile idref and return cis-audit level."""
208+ profiles = { # name: match_string
209+ "level1_server": "cis_profile_Level_1_Server",
210+ "level2_server": "cis_profile_Level_2_Server",
211+ "level1_workstation": "cis_profile_Level_1_Workstation",
212+ "level2_workstation": "cis_profile_Level_2_Workstation",
213+ }
214+ for profile in profiles:
215+ if profile_idref.endswith(profiles[profile]):
216+ return profile
217+
218+ msg = "CRITICAL: could not determine profile from idref '{}'"
219+ raise CriticalError(msg.format(profile_idref))
220+
221+
222+def get_audit_score_and_profile(results_filepath):
223+ """Extract audit score and profile level from results xml file."""
224+ try:
225+ root = ElementTree.parse(results_filepath).getroot()
226+ namespace = root.tag.split("Benchmark")[0]
227+ score = root.find(namespace + "TestResult/" + namespace + "score").text
228+ profile_xml = root.find(namespace + "TestResult/" + namespace + "profile")
229+ profile = parse_profile_idref(profile_xml.attrib["idref"])
230+ except ElementTree.ParseError as parse_error:
231+ msg = "CRITICAL: Could not parse audit results file '{}': '{}'"
232+ raise CriticalError(msg.format(results_filepath, parse_error))
233+ except PermissionError as permission_error:
234+ msg = "CRITICAL: Could not read audit results file '{}': {}"
235+ raise CriticalError(msg.format(results_filepath, permission_error))
236+ return float(score), profile
237+
238+
239+def check_cis_audit(target_profile, max_age, warning, critical):
240+ """Check if recent audit report exists and score and level are as specified."""
241+ results_filepath = get_audit_result_filepath()
242+ check_file_max_age(max_age, results_filepath)
243+ score, profile = get_audit_score_and_profile(results_filepath)
244+
245+ msg = "{}: cis-audit score is {:.2f} of 100; threshold -c {} -w {} ({}; {})"
246+ if score < critical:
247+ raise CriticalError(
248+ msg.format("CRITICAL", score, critical, warning, profile, results_filepath)
249+ )
250+ if score < warning:
251+ raise WarnError(
252+ msg.format("WARNING", score, critical, warning, profile, results_filepath)
253+ )
254+
255+ if target_profile != "" and target_profile != profile:
256+ msg = (
257+ "CRITICAL: requested audit profile '{}' does not match "
258+ "report profile '{}' from '{}'"
259+ ).format(target_profile, profile, results_filepath)
260+ raise CriticalError(msg)
261+
262+ print("OK: cis-audit score is {:.2f} of 100 (profile: {})".format(score, profile))
263+
264+
265+def parse_args(args):
266+ """Parse command-line options."""
267+ parser = argparse.ArgumentParser(
268+ prog=__file__,
269+ description="Check CIS audit score",
270+ )
271+ parser.add_argument(
272+ "--max-age",
273+ "-a",
274+ type=int,
275+ help="maximum age (h) of audit result file before alerting (default 170)",
276+ default=170,
277+ )
278+ parser.add_argument(
279+ "--cis-profile",
280+ "-p",
281+ choices=[
282+ "",
283+ "level1_server",
284+ "level2_server",
285+ "level1_workstation",
286+ "level2_workstation",
287+ ],
288+ help="cis-audit level parameter (verifies if audit report matches)",
289+ default="",
290+ )
291+ parser.add_argument(
292+ "--warn",
293+ "-w",
294+ type=int,
295+ help="a score below this number results in status WARNING (default: -1)",
296+ default=-1,
297+ )
298+ parser.add_argument(
299+ "--crit",
300+ "-c",
301+ type=int,
302+ help="a score below this number results in status CRITICAL (default: -1)",
303+ default=-1,
304+ )
305+ args = parser.parse_args(args)
306+ return args
307+
308+
309+def main():
310+ """Parse args and check the audit report."""
311+ args = parse_args(sys.argv[1:])
312+ try_check(check_cis_audit, args.cis_profile, args.max_age, args.warn, args.crit)
313+
314+
315+if __name__ == "__main__":
316+ main()
317diff --git a/files/plugins/cron_cis_audit.py b/files/plugins/cron_cis_audit.py
318new file mode 100755
319index 0000000..706337b
320--- /dev/null
321+++ b/files/plugins/cron_cis_audit.py
322@@ -0,0 +1,138 @@
323+#!/usr/bin/python3
324+"""Run cis-audit if latest results are outdated."""
325+
326+import argparse
327+import glob
328+import grp
329+import os
330+import random
331+import re
332+import subprocess
333+import sys
334+import time
335+
336+
337+AUDIT_FOLDER = "/usr/share/ubuntu-scap-security-guides"
338+AUDIT_RESULT_GLOB = AUDIT_FOLDER + "/cis-*-results.xml"
339+CLOUD_INIT_LOG = "/var/log/cloud-init-output.log"
340+DEFAULT_PROFILE = "level1_server"
341+PROFILES = [
342+ "level1_server",
343+ "level2_server",
344+ "level1_workstation",
345+ "level2_workstation",
346+]
347+MAX_SLEEP = 600
348+PID_FILENAME = "/tmp/cron_cis_audit.pid"
349+
350+
351+def _get_cis_hardening_profile(profile):
352+ """Try to read the cis profile from cloud init log or default to level1_server."""
353+ if profile in PROFILES:
354+ return profile
355+
356+ if not os.path.exists(CLOUD_INIT_LOG) or not os.access(CLOUD_INIT_LOG, os.R_OK):
357+ print(
358+ "{} not existing/accessible, default to profile '{}'".format(
359+ CLOUD_INIT_LOG, DEFAULT_PROFILE
360+ )
361+ )
362+ return DEFAULT_PROFILE
363+ pattern = re.compile(r"Applying Level-(1|2) scored (server|workstation)")
364+ for _, line in enumerate(open(CLOUD_INIT_LOG)):
365+ for match in re.finditer(pattern, line):
366+ level, machine_type = match.groups()
367+ return "level{}_{}".format(level, machine_type)
368+ return DEFAULT_PROFILE
369+
370+
371+def _get_cis_result_age():
372+ """Get the age of the newest audit results file."""
373+ audit_files = glob.glob(AUDIT_RESULT_GLOB)
374+ if not audit_files:
375+ return False
376+ if len(audit_files) >= 1:
377+ audit_file = sorted(audit_files, key=os.path.getmtime).pop()
378+ return (time.time() - os.path.getmtime(audit_file)) / 3600
379+
380+
381+def run_audit(profile):
382+ """Execute the cis-audit as subprocess and allow nagios group to read result."""
383+ cmd_run_audit = ["/usr/sbin/cis-audit", profile]
384+ sleep_time = random.randint(0, MAX_SLEEP)
385+ print("Sleeping for {}s to randomize the cis-audit start time".format(sleep_time))
386+ time.sleep(sleep_time)
387+ try:
388+ print("Run cis-audit: {}".format(cmd_run_audit), flush=True)
389+ subprocess.run(
390+ cmd_run_audit, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
391+ )
392+ print("Done cis-audit, change group of result file to nagios", flush=True)
393+ for file in glob.glob(AUDIT_RESULT_GLOB):
394+ os.chown(file, 0, grp.getgrnam("nagios").gr_gid)
395+ except subprocess.CalledProcessError as e:
396+ sys.exit(
397+ "Failed running command '{}' Return Code {}: {}".format(
398+ cmd_run_audit, e.returncode, e.output
399+ )
400+ )
401+
402+
403+def parse_args(args):
404+ """Parse command-line options."""
405+ parser = argparse.ArgumentParser(
406+ prog=__file__,
407+ description="Run cis-audit if report is outdated",
408+ )
409+ parser.add_argument(
410+ "--max-age",
411+ "-a",
412+ type=int,
413+ help="maximum age (h) of audit result file before alerting (default 170)",
414+ default=170,
415+ )
416+ profile_options = PROFILES + [""]
417+ parser.add_argument(
418+ "--cis-profile",
419+ "-p",
420+ choices=profile_options,
421+ default="",
422+ type=str,
423+ help="cis-audit level parameter (verifies if audit report matches)",
424+ )
425+ return parser.parse_args(args)
426+
427+
428+def main():
429+ """Run cis-audit if audit results are outdated."""
430+ args = parse_args(sys.argv[1:])
431+
432+ # folder does not exist - usg-cisbenchmark likely not installed
433+ if not os.path.exists(AUDIT_FOLDER):
434+ raise FileNotFoundError(
435+ "Folder {} does not exist, is usg-cisbenchmark installed?".format(
436+ AUDIT_FOLDER
437+ )
438+ )
439+
440+ # Ensure a single instance via a simple pidfile
441+ pid = str(os.getpid())
442+
443+ if os.path.isfile(PID_FILENAME):
444+ sys.exit("{} already exists, exiting".format(PID_FILENAME))
445+
446+ with open(PID_FILENAME, "w") as f:
447+ f.write(pid)
448+
449+ try:
450+ # audit result file does not exist or is outdated
451+ audit_file_age_hours = _get_cis_result_age()
452+ if audit_file_age_hours is False or audit_file_age_hours > args.max_age:
453+ profile = _get_cis_hardening_profile(args.cis_profile)
454+ run_audit(profile)
455+ finally:
456+ os.unlink(PID_FILENAME)
457+
458+
459+if __name__ == "__main__":
460+ main()
461diff --git a/files/plugins/nagios_plugin3.py b/files/plugins/nagios_plugin3.py
462new file mode 120000
463index 0000000..045dad3
464--- /dev/null
465+++ b/files/plugins/nagios_plugin3.py
466@@ -0,0 +1 @@
467+../nagios_plugin3.py
468\ No newline at end of file
469diff --git a/hooks/nrpe_helpers.py b/hooks/nrpe_helpers.py
470index b5631b6..6887069 100644
471--- a/hooks/nrpe_helpers.py
472+++ b/hooks/nrpe_helpers.py
473@@ -443,7 +443,7 @@ class NRPECheckCtxt(dict):
474 class SubordinateCheckDefinitions(dict):
475 """Return dict of checks the charm configures."""
476
477- def __init__(self):
478+ def __init__(self): # noqa: C901
479 """Set dict values."""
480 self.procs = self.proc_count()
481 load_thresholds = self._get_load_thresholds()
482@@ -483,6 +483,19 @@ class SubordinateCheckDefinitions(dict):
483 },
484 ]
485
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+
499 if not is_container():
500 checks.extend(
501 [
502diff --git a/hooks/nrpe_utils.py b/hooks/nrpe_utils.py
503index bc342b0..14ce194 100644
504--- a/hooks/nrpe_utils.py
505+++ b/hooks/nrpe_utils.py
506@@ -91,12 +91,9 @@ def install_charm_files(service_name):
507 charm_plugin_dir = os.path.join(charm_file_dir, "plugins")
508 pkg_plugin_dir = "/usr/lib/nagios/plugins/"
509 local_plugin_dir = "/usr/local/lib/nagios/plugins/"
510+ nagios_plugin = "nagios_plugin3.py"
511
512 shutil.copy2(
513- os.path.join(charm_file_dir, "nagios_plugin.py"),
514- pkg_plugin_dir + "/nagios_plugin.py",
515- )
516- shutil.copy2(
517 os.path.join(charm_file_dir, "nagios_plugin3.py"),
518 pkg_plugin_dir + "/nagios_plugin3.py",
519 )
520@@ -104,9 +101,12 @@ def install_charm_files(service_name):
521 shutil.copy2(os.path.join(charm_file_dir, "rsyncd.conf"), "/etc/rsyncd.conf")
522 host.mkdir("/etc/rsync-juju.d", perms=0o755)
523 host.rsync(charm_plugin_dir, "/usr/local/lib/nagios/", options=["--executability"])
524- for nagios_plugin in ("nagios_plugin.py", "nagios_plugin3.py"):
525- if not os.path.exists(local_plugin_dir + nagios_plugin):
526- os.symlink(pkg_plugin_dir + nagios_plugin, local_plugin_dir + nagios_plugin)
527+
528+ if not os.path.exists(local_plugin_dir + nagios_plugin):
529+ os.symlink(
530+ os.path.join(pkg_plugin_dir, nagios_plugin),
531+ os.path.join(local_plugin_dir, nagios_plugin),
532+ )
533
534
535 def render_nrpe_check_config(checkctxt):
536@@ -202,6 +202,24 @@ def has_consumer():
537 )
538
539
540+def update_cis_audit_cronjob(service_name):
541+ """Install/Remove the cis-audit cron job."""
542+ crond_file = "/etc/cron.d/cis-audit"
543+
544+ if not hookenv.config("cis_audit_enabled"):
545+ if os.path.exists(crond_file):
546+ os.remove(crond_file)
547+ hookenv.log("Cronjob removed at {}".format(crond_file), hookenv.DEBUG)
548+ return
549+
550+ file = "/usr/local/lib/nagios/plugins/cron_cis_audit.py"
551+ profile = hookenv.config("cis_audit_profile")
552+ cronjob = "*/10 * * * * root ({} -p '{}') 2>&1 | logger -t {}\n"
553+ with open(crond_file, "w") as crond_fd:
554+ crond_fd.write(cronjob.format(file, profile, "cron_cis_audit"))
555+ hookenv.log("Cronjob configured at {}".format(crond_file), hookenv.DEBUG)
556+
557+
558 class TolerantPortManagerCallback(PortManagerCallback):
559 """Manage unit ports.
560
561diff --git a/hooks/services.py b/hooks/services.py
562index df360bd..34b9887 100644
563--- a/hooks/services.py
564+++ b/hooks/services.py
565@@ -37,6 +37,7 @@ manager = ServiceManager(
566 nrpe_utils.update_monitor_relation,
567 nrpe_utils.create_host_export_fragment,
568 nrpe_utils.render_nrped_files,
569+ nrpe_utils.update_cis_audit_cronjob,
570 helpers.render_template(
571 source="nrpe.tmpl", target="/etc/nagios/nrpe.cfg"
572 ),
573diff --git a/mod/charmhelpers b/mod/charmhelpers
574index b53f741..446cbfd 160000
575--- a/mod/charmhelpers
576+++ b/mod/charmhelpers
577@@ -1 +1 @@
578-Subproject commit b53f741d1c6f34f26f889d79afaad838dc14fdfa
579+Subproject commit 446cbfdad83e15b5cfd20f862d3c3b5b1956b998
580diff --git a/pytest.ini b/pytest.ini
581new file mode 100644
582index 0000000..d347a54
583--- /dev/null
584+++ b/pytest.ini
585@@ -0,0 +1,4 @@
586+[pytest]
587+# 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
588+filterwarnings =
589+ ignore::DeprecationWarning
590diff --git a/tests/functional/tests/nrpe_tests.py b/tests/functional/tests/nrpe_tests.py
591index 1b626a6..53fb6b9 100644
592--- a/tests/functional/tests/nrpe_tests.py
593+++ b/tests/functional/tests/nrpe_tests.py
594@@ -281,6 +281,29 @@ class TestNrpe(TestBase):
595 self._get_set_comparison(expected_host_only_checks, container_checks),
596 )
597
598+ def test_07_cronjob_checks(self):
599+ """Check that cron job is installed and check enabled."""
600+ model.set_application_config(
601+ self.application_name,
602+ {
603+ "cis_audit_enabled": "True",
604+ },
605+ )
606+ model.block_until_all_units_idle()
607+ host_checks = self._get_unit_check_files("rabbitmq-server/0")
608+ expected_shared_checks = set(["check_cis_audit.cfg"])
609+ self.assertTrue(
610+ expected_shared_checks.issubset(host_checks),
611+ self._get_set_comparison(expected_shared_checks, host_checks),
612+ )
613+
614+ cronjobs = self._get_cronjob_files("rabbitmq-server/0")
615+ expected_cronjobs = set(["cis-audit"])
616+ self.assertTrue(
617+ expected_cronjobs.issubset(cronjobs),
618+ self._get_set_comparison(expected_cronjobs, cronjobs),
619+ )
620+
621 def _get_unit_check_files(self, unit):
622 cmdline = "ls /etc/nagios/nrpe.d/"
623 result = model.run_on_unit(unit, cmdline)
624@@ -295,3 +318,9 @@ class TestNrpe(TestBase):
625 "Actual:": actual_checks,
626 }
627 )
628+
629+ def _get_cronjob_files(self, unit):
630+ cmdline = "ls /etc/cron.d/"
631+ result = model.run_on_unit(unit, cmdline)
632+ self.assertEqual(result["Code"], "0")
633+ return set(result["Stdout"].splitlines())
634diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
635index 9d85006..9ddb28d 100644
636--- a/tests/unit/requirements.txt
637+++ b/tests/unit/requirements.txt
638@@ -2,3 +2,5 @@ coverage
639 six
640 PyYAML
641 netifaces
642+pytest
643+pytest-cov
644diff --git a/tests/unit/test_plugins_cis_audit.py b/tests/unit/test_plugins_cis_audit.py
645new file mode 100644
646index 0000000..e9a9319
647--- /dev/null
648+++ b/tests/unit/test_plugins_cis_audit.py
649@@ -0,0 +1,273 @@
650+"""Unit tests for files/plugins/(cron_cis_audit.py|check_cis_audit.py) module."""
651+
652+import argparse
653+import os
654+import tempfile
655+from io import StringIO
656+from time import sleep
657+from unittest import TestCase, mock
658+
659+from files.plugins import check_cis_audit, cron_cis_audit
660+
661+from nagios_plugin3 import CriticalError, WarnError
662+
663+DUMMY_LOGLINES = """
664+Processing triggers for man-db (2.8.3-2ubuntu0.1) ...
665+Processing triggers for libc-bin (2.27-3ubuntu1.4) ...
666+***Applying Level-2 scored server remediation for failures on a fresh Ubuntu 18.04 install***
667+""" # noqa: E501
668+
669+DUMMY_AUDIT_RESULT = """<?xml version="1.0" encoding="UTF-8"?>
670+<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">
671+ <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">
672+ <profile idref="xccdf_com.ubuntu.bionic.cis_profile_Level_1_Server"/>
673+ <score system="urn:xccdf:scoring:default" maximum="100.000000">89.444443</score>
674+ </TestResult>
675+</Benchmark>
676+""" # noqa: E501
677+
678+
679+class TestCronCisAudit(TestCase):
680+ """Test the cis-audit cron job functions."""
681+
682+ cloud_init_logfile = os.path.join(tempfile.gettempdir(), "cloud-init-output.log")
683+
684+ @classmethod
685+ def setUpClass(cls):
686+ """Create dummy log file."""
687+ with open(cls.cloud_init_logfile, "w") as f:
688+ f.write(DUMMY_LOGLINES)
689+
690+ @classmethod
691+ def tearDownClass(cls):
692+ """Delete dummy log file."""
693+ if os.path.exists(cls.cloud_init_logfile):
694+ os.remove(cls.cloud_init_logfile)
695+
696+ def test_get_cis_hardening_profile_default(self):
697+ """Test hardening profile passing defaults."""
698+ # default profile should be return if profile passed is invalid
699+ profile = cron_cis_audit._get_cis_hardening_profile("")
700+ self.assertEqual(
701+ profile,
702+ cron_cis_audit.DEFAULT_PROFILE,
703+ "Default profile should have been returned",
704+ )
705+ # parameter should be returned if parameter contains a valid profile
706+ expected_profile = cron_cis_audit.PROFILES[3]
707+ profile = cron_cis_audit._get_cis_hardening_profile(expected_profile)
708+ self.assertEqual(
709+ profile,
710+ expected_profile,
711+ "The profile in the parameter should have been returned",
712+ )
713+
714+ @mock.patch("files.plugins.cron_cis_audit.CLOUD_INIT_LOG", cloud_init_logfile)
715+ def test_get_cis_hardening_profile_cloudinit(self):
716+ """Test the detection of the hardening profile from cloudinit.log."""
717+ expected_profile = "level2_server"
718+ profile = cron_cis_audit._get_cis_hardening_profile("")
719+ self.assertEqual(
720+ profile,
721+ expected_profile,
722+ "Profile from Dummy file should be 'level2_server'",
723+ )
724+
725+ def test_get_cis_result_age(self):
726+ """Test file age function."""
727+ # file does not exist, returns false
728+ self.assertFalse(cron_cis_audit._get_cis_result_age())
729+
730+ # file was created when test initiated, should return 0
731+ with mock.patch(
732+ "files.plugins.cron_cis_audit.AUDIT_RESULT_GLOB", self.cloud_init_logfile
733+ ):
734+ age_in_hours = cron_cis_audit._get_cis_result_age()
735+ self.assertLess(
736+ age_in_hours,
737+ 0.1,
738+ "File age should be small because the file was just created",
739+ )
740+
741+ @mock.patch("sys.stderr", new_callable=StringIO)
742+ def test_parse_args(self, mock_stderr):
743+ """Test the default parsing behavior of the argument parser."""
744+ # test empty parameters
745+ args = cron_cis_audit.parse_args([])
746+ self.assertEqual(args, argparse.Namespace(cis_profile="", max_age=170))
747+
748+ # test setting parameters
749+ args = cron_cis_audit.parse_args(["-a 1", "-p=level2_workstation"])
750+ self.assertEqual(
751+ args, argparse.Namespace(cis_profile="level2_workstation", max_age=1)
752+ )
753+
754+ # test setting invalid parameter
755+ with self.assertRaises(SystemExit):
756+ cron_cis_audit.parse_args(["-p=invalid-parameter-test"])
757+ self.assertRegex(
758+ mock_stderr.getvalue(), r"invalid choice: 'invalid-parameter-test'"
759+ )
760+
761+ @mock.patch("sys.argv", [])
762+ def test_main_raise_exception(self):
763+ """Test if main() raises FileNotFoundError if AUDIT_FOLDER does not exist."""
764+ with self.assertRaises(FileNotFoundError):
765+ cron_cis_audit.main()
766+
767+ @mock.patch("files.plugins.cron_cis_audit.MAX_SLEEP", 1)
768+ @mock.patch("files.plugins.cron_cis_audit.AUDIT_FOLDER", "/tmp")
769+ @mock.patch("sys.argv", [])
770+ def test_main_run_audit(self):
771+ """Test if main() calles cis-audit is called with correct arguments."""
772+ with mock.patch("subprocess.run") as mock_subprocess_run:
773+ process_mock = mock.Mock()
774+ attrs = {"communicate.return_value": ("output", "error")}
775+ process_mock.configure_mock(**attrs)
776+ mock_subprocess_run.return_value = process_mock
777+ cron_cis_audit.main()
778+ self.assertTrue(mock_subprocess_run.called)
779+ self.assertEqual(
780+ str(mock_subprocess_run.call_args),
781+ "call(['/usr/sbin/cis-audit', 'level1_server'], stdout=-3, stderr=-3)",
782+ )
783+
784+
785+class TestCheckCisAudit(TestCase):
786+ """Test the cis-audit cron job functions."""
787+
788+ audit_result_folder = os.path.join(tempfile.gettempdir(), "test-audit-result")
789+ audit_results_glob = audit_result_folder + "/cis-*-results.xml"
790+ testfile1 = os.path.join(audit_result_folder, "cis-testfile1-results.xml")
791+ testfile2 = os.path.join(audit_result_folder, "cis-testfile2-results.xml")
792+
793+ @classmethod
794+ def setUpClass(cls):
795+ """Create dummy audit folder and files."""
796+ if not os.path.exists(cls.audit_result_folder):
797+ os.mkdir(cls.audit_result_folder)
798+ with open(cls.testfile1, mode="a"):
799+ pass # create empty file
800+ sleep(0.1)
801+ with open(cls.testfile2, mode="w") as f:
802+ f.write(DUMMY_AUDIT_RESULT)
803+
804+ @classmethod
805+ def tearDownClass(cls):
806+ """Delete dummy log file."""
807+ if os.path.exists(cls.audit_result_folder):
808+ for file in os.listdir(cls.audit_result_folder):
809+ os.remove(os.path.join(cls.audit_result_folder, file))
810+ os.rmdir(cls.audit_result_folder)
811+
812+ def test_get_audit_result_filepath_not_found(self):
813+ """Test that the audit results file can be found."""
814+ with self.assertRaises(CriticalError):
815+ check_cis_audit.get_audit_result_filepath()
816+
817+ @mock.patch("files.plugins.check_cis_audit.AUDIT_RESULT_GLOB", audit_results_glob)
818+ def test_get_audit_result_filepath_found(self):
819+ """Test that the newest audit file is returned."""
820+ audit_result_filepath = check_cis_audit.get_audit_result_filepath()
821+ expected = os.path.join(self.audit_result_folder, "cis-testfile2-results.xml")
822+ self.assertEqual(audit_result_filepath, expected)
823+
824+ def test_check_file_max_age(self):
825+ """Test that an exception is raised if the file is too old."""
826+ with self.assertRaises(CriticalError):
827+ check_cis_audit.check_file_max_age(0, self.testfile1)
828+
829+ def test_parse_profile_idref(self):
830+ """Test that profile parsing works correctly."""
831+ with self.assertRaises(CriticalError):
832+ check_cis_audit.parse_profile_idref("unknown_profile")
833+
834+ profile_id = "xccdf_com.ubuntu.bionic.cis_profile_Level_2_Workstation"
835+ self.assertEqual(
836+ "level2_workstation", check_cis_audit.parse_profile_idref(profile_id)
837+ )
838+
839+ def test_get_audit_score_and_profile(self):
840+ """Test the parsing of the audit results file."""
841+ # empty file raises CriticalError
842+ with self.assertRaises(CriticalError):
843+ check_cis_audit.get_audit_score_and_profile(self.testfile1)
844+
845+ # score and profile correctly read from xml
846+ score, profile = check_cis_audit.get_audit_score_and_profile(self.testfile2)
847+ self.assertEqual(score, 89.444443)
848+ self.assertEqual(profile, "level1_server")
849+
850+ @mock.patch("sys.argv", [])
851+ def test_parse_args(self):
852+ """Test the argument parsing."""
853+ # test default arguments
854+ arguments = check_cis_audit.parse_args([])
855+ self.assertEqual(
856+ arguments,
857+ argparse.Namespace(
858+ cis_profile="",
859+ crit=-1,
860+ max_age=170,
861+ warn=-1,
862+ ),
863+ )
864+
865+ # test setting arguments
866+ arguments = check_cis_audit.parse_args(
867+ ["-a", "1", "-c", "99", "-w", "90", "-p", "level2_server"]
868+ )
869+ self.assertEqual(
870+ arguments,
871+ argparse.Namespace(
872+ cis_profile="level2_server",
873+ crit=99,
874+ max_age=1,
875+ warn=90,
876+ ),
877+ )
878+
879+ @mock.patch("files.plugins.check_cis_audit.AUDIT_RESULT_GLOB", audit_results_glob)
880+ def test_check_cis_audit(self):
881+ """Test the check function with different parameters."""
882+ # all ok
883+ check_cis_audit.check_cis_audit("", 1, 80, 85)
884+
885+ # too old
886+ with self.assertRaises(CriticalError) as error:
887+ check_cis_audit.check_cis_audit("", 0, 80, 85)
888+ self.assertRegex(
889+ str(error.exception),
890+ "CRITICAL: The audit result file age 0.00h is older than threshold.*",
891+ )
892+
893+ # score below warning
894+ with self.assertRaises(WarnError) as error:
895+ check_cis_audit.check_cis_audit("", 1, 90, 80)
896+ self.assertRegex(
897+ str(error.exception),
898+ "WARNING: cis-audit score is 89.44 of 100; threshold -c 80 -w 90",
899+ )
900+
901+ # score below critical
902+ with self.assertRaises(CriticalError) as error:
903+ check_cis_audit.check_cis_audit("", 1, 95, 90)
904+ self.assertRegex(
905+ str(error.exception),
906+ "CRITICAL: cis-audit score is 89.44 of 100; threshold -c 90 -w 95",
907+ )
908+
909+ # profile does not match
910+ with self.assertRaises(CriticalError) as error:
911+ check_cis_audit.check_cis_audit("level2_workstation", 1, 85, 80)
912+ self.assertRegex(
913+ str(error.exception),
914+ "CRITICAL: requested audit profile 'level2_workstation' does not match",
915+ )
916+
917+ @mock.patch("files.plugins.check_cis_audit.AUDIT_RESULT_GLOB", audit_results_glob)
918+ def test_main(self):
919+ """Test the main function."""
920+ namespace = argparse.Namespace(cis_profile="", max_age=1, crit=80, warn=70)
921+ with mock.patch("argparse.ArgumentParser.parse_args", return_value=namespace):
922+ check_cis_audit.main()
923diff --git a/tox.ini b/tox.ini
924index 096b228..21b6e4b 100644
925--- a/tox.ini
926+++ b/tox.ini
927@@ -64,9 +64,8 @@ deps =
928
929 [testenv:unit]
930 commands =
931- coverage run -m unittest discover -s {toxinidir}/tests/unit -v
932- coverage report --omit tests/*,mod/*,.tox/*
933- coverage html --omit tests/*,mod/*,.tox/*
934+ pytest {posargs:-v --cov --cov-config={toxinidir}/.coveragerc --cov-report=term-missing --cov-report html --cov-branch --ignore={toxinidir}/tests/charmhelpers} \
935+ {toxinidir}/tests/unit
936 deps = -r{toxinidir}/tests/unit/requirements.txt
937
938 [testenv:func]

Subscribers

People subscribed via source and target branches

to all changes: