Merge ~jfguedez/charm-nrpe:cis-hardening-check into charm-nrpe:master
- Git
- lp:~jfguedez/charm-nrpe
- cis-hardening-check
- Merge into master
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) |
||||
Related bugs: |
|
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:/
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
A CI job is currently in progress. A follow up comment will be added when it completes.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:1b3c96df945
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
James Troup (elmo) wrote : | # |
Thanks for picking this work up - see comments inline.
Jose Guedez (jfguedez) wrote : | # |
Thanks for the review. I have addressed the issues and pushed the changes. Replies inline.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
A CI job is currently in progress. A follow up comment will be added when it completes.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:a9752abce71
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
Jose Guedez (jfguedez) wrote : | # |
A different MP was merged overnight, causing a conflict in tox.ini. Rebased to master and pushed again
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
A CI job is currently in progress. A follow up comment will be added when it completes.
🤖 prod-jenkaas-bootstack (prod-jenkaas-bootstack) wrote : | # |
PASSED: Continuous integration, rev:628764521ce
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision 270a31d7643f914
Preview Diff
1 | diff --git a/.coveragerc b/.coveragerc |
2 | index 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/* |
10 | diff --git a/config.yaml b/config.yaml |
11 | index 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 |
54 | diff --git a/files/nagios_plugin.py b/files/nagios_plugin.py |
55 | deleted file mode 100644 |
56 | index 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 | -################################################################################ |
144 | diff --git a/files/plugins/check_cis_audit.py b/files/plugins/check_cis_audit.py |
145 | new file mode 100755 |
146 | index 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() |
317 | diff --git a/files/plugins/cron_cis_audit.py b/files/plugins/cron_cis_audit.py |
318 | new file mode 100755 |
319 | index 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() |
461 | diff --git a/files/plugins/nagios_plugin3.py b/files/plugins/nagios_plugin3.py |
462 | new file mode 120000 |
463 | index 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 |
469 | diff --git a/hooks/nrpe_helpers.py b/hooks/nrpe_helpers.py |
470 | index 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 | [ |
502 | diff --git a/hooks/nrpe_utils.py b/hooks/nrpe_utils.py |
503 | index 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 | |
561 | diff --git a/hooks/services.py b/hooks/services.py |
562 | index 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 | ), |
573 | diff --git a/mod/charmhelpers b/mod/charmhelpers |
574 | index b53f741..446cbfd 160000 |
575 | --- a/mod/charmhelpers |
576 | +++ b/mod/charmhelpers |
577 | @@ -1 +1 @@ |
578 | -Subproject commit b53f741d1c6f34f26f889d79afaad838dc14fdfa |
579 | +Subproject commit 446cbfdad83e15b5cfd20f862d3c3b5b1956b998 |
580 | diff --git a/pytest.ini b/pytest.ini |
581 | new file mode 100644 |
582 | index 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 |
590 | diff --git a/tests/functional/tests/nrpe_tests.py b/tests/functional/tests/nrpe_tests.py |
591 | index 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()) |
634 | diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt |
635 | index 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 |
644 | diff --git a/tests/unit/test_plugins_cis_audit.py b/tests/unit/test_plugins_cis_audit.py |
645 | new file mode 100644 |
646 | index 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() |
923 | diff --git a/tox.ini b/tox.ini |
924 | index 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] |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.