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