├── README.md ├── library ├── scan_cron.py ├── scan_processes.py ├── scan_sudoers.py └── scan_user_group.py └── test ├── library ├── scan_cron.yml ├── scan_processes.yml ├── scan_sudoers.yml └── scan_user_group.yml /README.md: -------------------------------------------------------------------------------- 1 | # ansible-fact 2 | ## Table of Contents 3 | 4 | 5 | 1. [Table of Contents](#table-of-contents) 6 | 2. [About](#about) 7 | 3. [Available Modules](#available-modules) 8 | 4. [Module Documentation](#module-documentation) 9 | 5. [Contributions](#contributions) 10 | 1. [Guidelines](#guidelines) 11 | 6. [Author(s)](#authors) 12 | 13 | 14 | 15 | ## About 16 | The concept of this project is to perform Infrastructure-as-Code in Reverse (i.e. iacir - pronounced: aya sir). 17 | 18 | `ansible-fact` consists of a collection of Ansible fact collectors to be able to generate structured data and harvest system configurations. The goal is to be able to automatically collect all aspects of a system's configuration through modules. 19 | 20 | ## Available Modules 21 | | Module Name | Description | Test Playbook | 22 | | --- | --- | --- | 23 | | [scan_cron](library/scan_cron.py) | Collects all cron data from a system and converts to structured data | [scan_cron.yml](test/scan_cron.yml) | 24 | | [scan_processes](library/scan_processes.py) | Collects currently running processes from a system and converts to structured data | [scan_processes.yml](test/scan_processes.yml) | 25 | | [scan_sudoers](library/scan_sudoers.py) | Collects all sudoers configurations and converts to structured data | [scan_sudoers.yml](test/scan_sudoers.yml) | 26 | | [scan_user_group](library/scan_user_group.py) | Collects all local user and group data from `/etc/shadow`, `/etc/gshadow`, `/etc/passwd`, and `/etc/group`, and merges into structured data. | [scan_user_group.yml](test/scan_user_group.yml) 27 | 28 | ## Module Documentation 29 | All module documentation can be found in each respective module, as with any Ansible module. 30 | 31 | ## Contributions 32 | Please feel free to openly contribute to this project. All code will be reviewed prior to merging. 33 | 34 | ### Guidelines 35 | * Please perform all development and pull requests against the `dev` branch of this repository. 36 | * If a particular fact collector can apply to many different Operating Systems, please try and accommodate all Operating System implementations in an attempt to keep this project platform agnostic. 37 | * Please include a test playbook in the [test](test) directory. 38 | * Please place your modules in the [library](library) directory. 39 | * Please document your code and modules thoroughly via comments and by following [Ansible's Module Development Documentation](https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html#starting-a-new-module). 40 | 41 | ## Author(s) 42 | [Andrew J. Huffman](https://github.com/ahuffman) 43 | -------------------------------------------------------------------------------- /library/scan_cron.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright: (c) 2019, Andrew J. Huffman 4 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | 6 | ANSIBLE_METADATA = {'metadata_version': '1.1', 7 | 'status': ['preview'], 8 | 'supported_by': 'community'} 9 | 10 | DOCUMENTATION = ''' 11 | --- 12 | module: scan_cron 13 | short_description: Collects cron job facts 14 | version_added: "2.8" 15 | description: 16 | - "Collects cron job facts from a system." 17 | - "The module can display both parsed and raw cron configurations which is useful when some cron jobs are scripts and others are true schedules." 18 | - "The display of either raw configurations or parsed configurations can be limited via the module parameters." 19 | options: 20 | output_raw_configs: 21 | description: 22 | - Whether or not to output raw configuration lines (excluding comments) from the scanned cron files 23 | default: True 24 | required: False 25 | output_parsed_configs: 26 | description: 27 | - Whether or not to output parsed data from the scanned cron files 28 | default: True 29 | required: False 30 | author: 31 | - Andrew J. Huffman (@ahuffman) 32 | ''' 33 | 34 | EXAMPLES = ''' 35 | # Collect all cron data (defaults) 36 | - name: "Collect all cron data" 37 | scan_cron: 38 | 39 | # Collect only raw configurations (minus comments) 40 | - name: "Collect raw cron configs" 41 | scan_cron: 42 | output_parsed_configs: False 43 | 44 | # Collect only parsed configuration data 45 | # This is only useful if you have no scripting logic in the cron files (i.e. if's, do untils, etc.) 46 | - name: "Collect parsed cron data" 47 | scan_cron: 48 | output_raw_configs: False 49 | ''' 50 | 51 | RETURN = ''' 52 | # From a default Fedora configuration 53 | cron: 54 | all_scanned_files: 55 | - /etc/crontab 56 | - /etc/cron.hourly/0anacron 57 | - /etc/cron.weekly/98-zfs-fuse-scrub 58 | - /var/spool/cron/root 59 | - /etc/cron.d/0hourly 60 | - /etc/cron.d/raid-check 61 | allow: 62 | path: /etc/cron.allow 63 | users: [] 64 | deny: 65 | path: /etc/cron.deny 66 | users: [] 67 | files: 68 | - configuration: 69 | - SHELL=/bin/bash 70 | - PATH=/sbin:/bin:/usr/sbin:/usr/bin 71 | - MAILTO=root 72 | data: 73 | schedules: [] 74 | variables: 75 | - name: SHELL 76 | value: /bin/bash 77 | - name: PATH 78 | value: /sbin:/bin:/usr/sbin:/usr/bin 79 | - name: MAILTO 80 | value: root 81 | path: /etc/crontab 82 | - configuration: 83 | - '#!/usr/bin/sh' 84 | - if test -r /var/spool/anacron/cron.daily; then 85 | - ' day=`cat /var/spool/anacron/cron.daily`' 86 | - fi 87 | - if [ `date +%Y%m%d` = "$day" ]; then 88 | - ' exit 0' 89 | - fi 90 | - online=1 91 | - for psupply in AC ADP0 ; do 92 | - ' sysfile="/sys/class/power_supply/$psupply/online"' 93 | - ' if [ -f $sysfile ] ; then' 94 | - ' if [ `cat $sysfile 2>/dev/null`x = 1x ]; then' 95 | - ' online=1' 96 | - ' break' 97 | - ' else' 98 | - ' online=0' 99 | - ' fi' 100 | - ' fi' 101 | - done 102 | - if [ $online = 0 ]; then 103 | - ' exit 0' 104 | - fi 105 | - /usr/sbin/anacron -s 106 | data: 107 | schedules: [] 108 | shell: /usr/bin/sh 109 | variables: 110 | - name: online 111 | value: '1' 112 | path: /etc/cron.hourly/0anacron 113 | - configuration: 114 | - '#!/usr/bin/bash' 115 | - '[ -f /etc/sysconfig/zfs-fuse ] || exit 0' 116 | - . /etc/sysconfig/zfs-fuse 117 | - '[ "$ZFS_WEEKLY_SCRUB" != "yes" ] && exit 0' 118 | - zpool=/usr/sbin/zpool 119 | - pools=`$zpool list -H | cut -f1` 120 | - if [ "$pools" != "" ] ; then 121 | - ' echo Found these pools: $pools' 122 | - ' for pool in $pools; do' 123 | - ' echo "Starting scrub of pool $pool"' 124 | - ' $zpool scrub $pool' 125 | - ' done' 126 | - ' echo "ZFS Fuse automatic scrub start done. Use ''$zpool status'' to see progress."' 127 | - fi 128 | data: 129 | schedules: [] 130 | shell: /usr/bin/bash 131 | variables: 132 | - name: zpool 133 | value: /usr/sbin/zpool 134 | - name: pools 135 | value: '`$zpool list -H | cut -f1`' 136 | path: /etc/cron.weekly/98-zfs-fuse-scrub 137 | - configuration: [] 138 | data: 139 | schedules: [] 140 | variables: [] 141 | path: /var/spool/cron/root 142 | - configuration: 143 | - SHELL=/bin/bash 144 | - PATH=/sbin:/bin:/usr/sbin:/usr/bin 145 | - MAILTO=root 146 | - 01 * * * * root run-parts /etc/cron.hourly 147 | data: 148 | schedules: 149 | - command: run-parts /etc/cron.hourly 150 | day_of_month: '*' 151 | day_of_week: '*' 152 | hour: '*' 153 | minute: '01' 154 | month: '*' 155 | user: root 156 | variables: 157 | - name: SHELL 158 | value: /bin/bash 159 | - name: PATH 160 | value: /sbin:/bin:/usr/sbin:/usr/bin 161 | - name: MAILTO 162 | value: root 163 | path: /etc/cron.d/0hourly 164 | - configuration: 165 | - 0 1 * * Sun root /usr/sbin/raid-check 166 | data: 167 | schedules: 168 | - command: /usr/sbin/raid-check 169 | day_of_month: '*' 170 | day_of_week: Sun 171 | hour: '1' 172 | minute: '0' 173 | month: '*' 174 | user: root 175 | variables: [] 176 | path: /etc/cron.d/raid-check 177 | ''' 178 | 179 | from ansible.module_utils.basic import AnsibleModule 180 | import os 181 | from os.path import isfile, isdir, join 182 | import re 183 | 184 | def main(): 185 | module_args = dict( 186 | output_raw_configs=dict( 187 | type='bool', 188 | default=True, 189 | required=False 190 | ), 191 | output_parsed_configs=dict( 192 | type='bool', 193 | default=True, 194 | required=False 195 | ) 196 | ) 197 | 198 | result = dict( 199 | changed=False, 200 | original_message='', 201 | message='' 202 | ) 203 | 204 | module = AnsibleModule( 205 | argument_spec=module_args, 206 | supports_check_mode=True 207 | ) 208 | 209 | params = module.params 210 | 211 | def get_cron_allow(): 212 | allow = dict() 213 | allow['path'] = '/etc/cron.allow' 214 | allow['users'] = list() 215 | try: 216 | cron_allow = open('/etc/cron.allow', 'r') 217 | for line in cron_allow: 218 | user = line.replace('\n', '') 219 | allow['users'].append(user) 220 | cron_allow.close() 221 | except: 222 | pass 223 | return allow 224 | 225 | def get_cron_deny(): 226 | deny = dict() 227 | deny['path'] = '/etc/cron.deny' 228 | deny['users'] = list() 229 | try: 230 | cron_deny = open('/etc/cron.deny', 'r') 231 | for line in cron_deny: 232 | user = line.replace('\n', '') 233 | deny['users'].append(user) 234 | cron_deny.close() 235 | except: 236 | pass 237 | return deny 238 | 239 | def get_cron_files(): 240 | # standard cron locations for cron file discovery 241 | cron_paths = [ 242 | "/etc/crontab" 243 | ] 244 | cron_dirs = [ 245 | "/etc/cron.hourly", 246 | "/etc/cron.daily", 247 | "/etc/cron.weekly", 248 | "/etc/cron.monthly", 249 | "/var/spool/cron", 250 | "/etc/cron.d" 251 | ] 252 | 253 | # Look for files in cron directories and append to cron_paths 254 | for dir in cron_dirs: 255 | try: 256 | cron_paths += [join(dir, filename) for filename in os.listdir(dir) if isfile(join(dir, filename))] 257 | # keep digging 258 | cron_dirs += [join(dir, filename) for filename in os.listdir(dir) if isdir(join(dir, filename))] 259 | except: 260 | pass 261 | return cron_paths 262 | 263 | def get_cron_data(cron_paths): 264 | # Output data 265 | cron_data = list() 266 | # Regex for parsing data 267 | variable_re = re.compile(r'^([a-zA-Z0-9_-]*)[ \t]*=[ \t]*(.*)$') 268 | comment_re = re.compile(r'^#+') 269 | shebang_re = re.compile(r'^(#!){1}(.*)$') 270 | schedule_re = re.compile(r'^([0-9a-zA-Z\*\-\,\/]+)[ \t]+([0-9a-zA-Z\*\-\,\/]+)[ \t]+([0-9a-zA-Z\*\-\,\/]+)[ \t]+([0-9a-zA-Z\*\-\,\/]+)[ \t]+([0-9a-zA-Z\*\-\,\/]+)[ \t]+([A-Za-z0-9\-\_]*)[ \t]*(.*)$') 271 | 272 | # work on each file that was found 273 | for cron in cron_paths: 274 | job = dict() 275 | job['path'] = cron 276 | 277 | if params['output_raw_configs']: 278 | job['configuration'] = list() 279 | 280 | if params['output_parsed_configs']: 281 | job['data'] = dict() 282 | job['data']['variables'] = list() 283 | job['data']['schedules'] = list() 284 | job['data']['shell'] = '' 285 | 286 | # make sure we have permission to open the files 287 | try: 288 | config = open(cron, 'r') 289 | for l in config: 290 | line = l.replace('\n', '').replace('\t', ' ') 291 | # raw configuration output 292 | if params['output_raw_configs']: 293 | # Not a comment line 294 | if comment_re.search(line) is None and line != '' and line != None: 295 | job['configuration'].append(line) 296 | 297 | # Shebang line 298 | elif shebang_re.search(line) and line != '' and line != None: 299 | job['configuration'].append(line) 300 | 301 | # parsed data output 302 | if params['output_parsed_configs']: 303 | variable = dict() 304 | sched = dict() 305 | 306 | # Capture script variables 307 | if variable_re.search(line) and line != '' and line != None: 308 | variable['name'] = variable_re.search(line).group(1) 309 | variable['value'] = variable_re.search(line).group(2) 310 | job['data']['variables'].append(variable) 311 | 312 | # Capture script shell type 313 | if shebang_re.search(line) and line != '' and line != None: 314 | job['data']['shell'] = shebang_re.search(line).group(2) 315 | 316 | # Capture cron schedules: 317 | ## don't try if a shell is set on the file, because it's a script at that point 318 | if schedule_re.search(line) and line != '' and line != None and job['data']['shell'] == '': 319 | sched['minute'] = schedule_re.search(line).group(1) 320 | sched['hour'] = schedule_re.search(line).group(2) 321 | sched['day_of_month'] = schedule_re.search(line).group(3) 322 | sched['month'] = schedule_re.search(line).group(4) 323 | sched['day_of_week'] = schedule_re.search(line).group(5) 324 | # optional user field in some implementations 325 | if schedule_re.search(line).group(6): 326 | sched['user'] = schedule_re.search(line).group(6) 327 | sched['command'] = schedule_re.search(line).group(7) 328 | job['data']['schedules'].append(sched) 329 | config.close() 330 | 331 | except: 332 | pass 333 | 334 | # append each parsed file 335 | cron_data.append(job) 336 | return cron_data 337 | 338 | # Do work 339 | cron_allow = get_cron_allow() 340 | cron_deny = get_cron_deny() 341 | cron_paths = get_cron_files() 342 | cron_data = get_cron_data(cron_paths) 343 | 344 | # Build output 345 | cron = dict() 346 | cron['allow'] = cron_allow 347 | cron['deny'] = cron_deny 348 | cron['all_scanned_files'] = cron_paths 349 | cron['files'] = cron_data 350 | result = {'ansible_facts': {'cron': cron}} 351 | 352 | module.exit_json(**result) 353 | 354 | 355 | if __name__ == '__main__': 356 | main() 357 | -------------------------------------------------------------------------------- /library/scan_processes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Copyright: (c) 2019, Andrew J. Huffman 4 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 | 6 | ANSIBLE_METADATA = {'metadata_version': '1.1', 7 | 'status': ['preview'], 8 | 'supported_by': 'community'} 9 | 10 | DOCUMENTATION = ''' 11 | --- 12 | module: scan_processes 13 | short_description: Collects currently running processes on a system 14 | version_added: "2.8" 15 | description: 16 | - "Collects the currently running processes on a system at the time the module is run." 17 | - "This module presents the currently running proceses as ansible_facts" 18 | output_ps_stdout_lines: 19 | description: 20 | - Whether or not to output the collected standard out lines from the 'ps auxww' command 21 | default: False 22 | required: False 23 | output_parsed_processes: 24 | description: 25 | - Whether or not to output parsed data from the 'ps auxww' command. 26 | default: True 27 | required: False 28 | author: 29 | - Andrew J. Huffman (@ahuffman) 30 | ''' 31 | 32 | EXAMPLES = ''' 33 | # Collect running processes and output parsed data 34 | - name: "Collect current running processes" 35 | scan_processes: 36 | 37 | # Collect only standard out lines from the ps auxww command 38 | - name: "Collect process command output" 39 | scan_processes: 40 | output_ps_stdout_lines: True 41 | output_parsed_processes: False 42 | 43 | # Collect both parsed process data and 'ps auxww' command standard out 44 | - name: "Collect all process data" 45 | scan_processes: 46 | output_ps_stdout_lines: True 47 | ''' 48 | 49 | RETURN = ''' 50 | running_processes: 51 | processes: 52 | - command: /usr/lib/systemd/systemd --switched-root --system --deserialize 33 53 | cpu_percentage: '0.0' 54 | memory_percentage: '0.0' 55 | pid: '1' 56 | resident_size: '5036' 57 | start: Jul08 58 | stat: Ss 59 | teletype: '?' 60 | time: '3:32' 61 | user: root 62 | ... 63 | ps_stdout_lines: 64 | - root 1 0.0 0.0 171628 5056 ? Ss Jul08 3:32 /usr/lib/systemd/systemd --switched-root --system --deserialize 33 65 | ... 66 | total_running_processes: 359 67 | ''' 68 | 69 | from ansible.module_utils.basic import AnsibleModule 70 | import os, re, subprocess 71 | from os.path import isfile, isdir, join 72 | 73 | def main(): 74 | module_args = dict( 75 | output_ps_stdout_lines=dict( 76 | type='bool', 77 | default=False, 78 | required=False 79 | ), 80 | output_parsed_processes=dict( 81 | type='bool', 82 | default=True, 83 | required=False 84 | ) 85 | ) 86 | 87 | result = dict( 88 | changed=False, 89 | original_message='', 90 | message='' 91 | ) 92 | 93 | module = AnsibleModule( 94 | argument_spec=module_args, 95 | supports_check_mode=True 96 | ) 97 | 98 | params = module.params 99 | 100 | def get_processes(): 101 | re_header = re.compile(r'^USER+.*') 102 | proc_stats = dict() 103 | procs = list() 104 | count = 0 105 | running = subprocess.check_output(["ps auxww"], universal_newlines=True, shell=True) 106 | for l in running.split('\n'): 107 | if len(l) > 0 and re_header.search(l) is None: 108 | procs.append(l.replace('\n', '').replace('\t', ' ')) 109 | count += 1 110 | proc_stats['stdout'] = procs 111 | proc_stats['total_running_processes'] = count 112 | return proc_stats 113 | 114 | def parse_process_data(procs): 115 | re_ps = re.compile(r'^(?P[\w\+\-\_\$]+)\s+(?P[0-9]+)\s+(?P[0-9\.]+)\s+(?P[0-9\.]+)\s+(?P[0-9]+)\s+(?P[0-9]+)\s+(?P[a-zA-Z0-9\?\/]+)\s+(?P[DIRSTtWXZ\[A-Za-z0-9\:]+)\s+(?P