diff --git a/group_vars/all/07_calendar.yml b/group_vars/all/07_calendar.yml index 63f73079..5cd3875d 100644 --- a/group_vars/all/07_calendar.yml +++ b/group_vars/all/07_calendar.yml @@ -5,6 +5,7 @@ on_calendar_health_journalctl: "*-*-* 00:00:00" on_calendar_health_disc_space: "*-*-* 06,12,18,00:00:00" # Check four times per day if there is sufficient disc space on_calendar_health_docker_container: "*-*-* {{ hours_server_awake }}:00:00" # Check once per hour if the docker containers are healthy on_calendar_health_docker_volumes: "*-*-* {{ hours_server_awake }}:15:00" # Check once per hour if the docker volumes are healthy +on_calendar_health_csp_crawler: "*-*-* {{ hours_server_awake }}:30:00" # Check once per hour if all CSP are fullfilled available on_calendar_health_nginx: "*-*-* {{ hours_server_awake }}:45:00" # Check once per hour if all webservices are available on_calendar_health_msmtp: "*-*-* 00:00:00" # Check once per day SMTP Server diff --git a/roles/health-csp/README.md b/roles/health-csp/README.md new file mode 100644 index 00000000..528f0e66 --- /dev/null +++ b/roles/health-csp/README.md @@ -0,0 +1,45 @@ +# Health CSP Crawler + +## Description + +This Ansible role automates the validation of [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) enforcement for all configured domains by crawling them using a Puppeteer-based Node.js script. + +## Overview + +Designed for Archlinux systems, this role periodically checks whether web resources (JavaScript, fonts, images, etc.) are blocked by CSP headers. It integrates Python and Node.js tooling and installs a systemd service with timer support. + +## Features + +- **CSP Resource Validation:** Uses Puppeteer to simulate browser requests and detect blocked resources. +- **Domain Extraction:** Parses all `.conf` files in the NGINX config folder to determine the list of domains to check. +- **Automated Execution:** Registers a systemd service and timer for recurring health checks. +- **Error Notification:** Integrates with `systemd-notifier` for alerting on failure. + +## Dependencies + +This role depends on the following: + +- [`nodejs`](../nodejs/) +- [`npm`](../npm/) +- [`systemd-notifier`](../systemd-notifier/) +- [`systemd-timer`](../systemd-timer/) + +## Configuration + +Set the following variables to customize behavior: + +```yaml +health_csp_crawler_folder: "{{ path_administrator_scripts }}health-csp/" +on_calendar_health_csp_crawler: "daily" +```` + +## License + +CyMaIS NonCommercial License (CNCL) +[https://s.veen.world/cncl](https://s.veen.world/cncl) + +## Author + +Kevin Veen-Birkenbach +Consulting & Coaching Solutions +[https://www.veen.world](https://www.veen.world) \ No newline at end of file diff --git a/roles/health-csp/files/__init__.py b/roles/health-csp/files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/roles/health-csp/files/health-csp.js b/roles/health-csp/files/health-csp.js new file mode 100644 index 00000000..82177d26 --- /dev/null +++ b/roles/health-csp/files/health-csp.js @@ -0,0 +1,43 @@ +const puppeteer = require('puppeteer'); +const domains = process.argv.slice(2); + +(async () => { + let errorCounter = 0; + const browser = await puppeteer.launch({ headless: 'new' }); + + for (const domain of domains) { + const page = await browser.newPage(); + const blockedResources = []; + + page.on('requestfailed', request => { + const reason = request.failure()?.errorText || ''; + if (reason.includes('blocked')) { + blockedResources.push({ url: request.url(), reason }); + } + }); + + try { + const url = `https://${domain}`; + await page.goto(url, { waitUntil: 'networkidle2', timeout: 20000 }); + } catch (e) { + console.error(`${domain}: ERROR visiting site - ${e.message}`); + errorCounter++; + continue; + } + + if (blockedResources.length > 0) { + console.warn(`${domain}: Blocked resources detected:`); + blockedResources.forEach(r => + console.log(` BLOCKED by CSP: ${r.url} (${r.reason})`) + ); + errorCounter++; + } else { + console.log(`${domain}: ✅ No CSP blocks detected.`); + } + + await page.close(); + } + + await browser.close(); + process.exit(errorCounter); +})(); diff --git a/roles/health-csp/files/health_csp.py b/roles/health-csp/files/health_csp.py new file mode 100644 index 00000000..7d3ad844 --- /dev/null +++ b/roles/health-csp/files/health_csp.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +import os +import re +import subprocess +import sys +import argparse + + +def extract_domains(config_path): + """ + Extracts domain names from .conf filenames in the given directory. + """ + domain_pattern = re.compile(r'^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\.conf$') + domains = [] + + try: + for filename in os.listdir(config_path): + if filename.endswith(".conf") and domain_pattern.match(filename): + domain = filename[:-5] # Remove ".conf" + domains.append(domain) + except FileNotFoundError: + print(f"Directory {config_path} not found.", file=sys.stderr) + return None + + return domains + + +def run_node_checker(script_path, domains): + """ + Executes the Node.js CSP checker script with the given domains. + """ + try: + result = subprocess.run( + ["/usr/bin/node", script_path] + domains, + check=True + ) + return result.returncode + except subprocess.CalledProcessError as e: + print(f"{os.path.basename(script_path)} reported issues (exit code {e.returncode})") + return e.returncode + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + return 1 + + +def main(): + parser = argparse.ArgumentParser(description="Check CSP-blocked resources via Puppeteer") + parser.add_argument("--nginx-config-dir", required=True, help="Directory containing NGINX .conf files") + parser.add_argument("--script", required=True, help="Path to Node.js CSP checker script") + args = parser.parse_args() + + domains = extract_domains(args.nginx_config_dir) + + if domains is None: + return 1 + + if not domains: + print("No domains found to check.") + return 0 + + return run_node_checker(args.script, domains) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/roles/health-csp/handlers/main.yml b/roles/health-csp/handlers/main.yml new file mode 100644 index 00000000..9328aeb7 --- /dev/null +++ b/roles/health-csp/handlers/main.yml @@ -0,0 +1,5 @@ +- name: "reload health-csp.cymais.service" + systemd: + name: health-csp.cymais.service + enabled: yes + daemon_reload: yes diff --git a/roles/health-csp/meta/main.yml b/roles/health-csp/meta/main.yml new file mode 100644 index 00000000..9d09f60b --- /dev/null +++ b/roles/health-csp/meta/main.yml @@ -0,0 +1,31 @@ +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "Checks for CSP-blocked resources via Puppeteer-based Node.js crawler" + license: "CyMaIS NonCommercial License (CNCL)" + license_url: "https://s.veen.world/cncl" + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + min_ansible_version: "2.9" + platforms: + - name: Archlinux + versions: + - rolling + galaxy_tags: + - csp + - puppeteer + - health + - browser + - nodejs + - monitoring + - systemd + repository: "https://s.veen.world/cymais" + issue_tracker_url: "https://s.veen.world/cymaisissues" + documentation: "https://s.veen.world/cymais" +dependencies: + - systemd-notifier + - nodejs + - role: npm + vars: + npm_project_folder: "{{ health_csp_crawler_folder }}" diff --git a/roles/health-csp/tasks/main.yml b/roles/health-csp/tasks/main.yml new file mode 100644 index 00000000..174e1979 --- /dev/null +++ b/roles/health-csp/tasks/main.yml @@ -0,0 +1,46 @@ +- name: "Install puppeteer if node_modules not yet present" + ansible.builtin.command: + cmd: npm install puppeteer + chdir: "{{ health_csp_crawler_folder }}" + creates: "{{ health_csp_crawler_folder }}/node_modules" + +- name: Check if puppeteer is usable + command: node -e "require('puppeteer')" + args: + chdir: "{{ health_csp_crawler_folder }}" + register: puppeteer_check + failed_when: puppeteer_check.rc != 0 + +- name: "create {{ health_csp_crawler_folder }}" + file: + path: "{{ health_csp_crawler_folder }}" + state: directory + mode: 0755 + +- name: copy health_csp.py + copy: + src: health_csp.py + dest: "{{ health_csp_crawler_script }}" + mode: 0755 + +- name: copy health-csp.js + copy: + src: health-csp.js + dest: "{{ health_csp_crawler_node }}" + mode: 0755 + +- name: create health-csp.cymais.service + template: + src: health-csp.service.j2 + dest: /etc/systemd/system/health-csp.cymais.service + notify: reload health-csp.cymais.service + +- name: set service_name to role_name + set_fact: + service_name: "{{ role_name }}" + +- name: include systemd timer role + include_role: + name: systemd-timer + vars: + on_calendar: "{{ on_calendar_health_csp_crawler }}" diff --git a/roles/health-csp/templates/health-csp.service.j2 b/roles/health-csp/templates/health-csp.service.j2 new file mode 100644 index 00000000..cd89ad74 --- /dev/null +++ b/roles/health-csp/templates/health-csp.service.j2 @@ -0,0 +1,9 @@ +[Unit] +Description=Check for CSP-blocked resources via Puppeteer +OnFailure=systemd-notifier.cymais@%n.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 {{ health_csp_crawler_script }} \ + --nginx-config-dir={{ nginx.directories.http.servers }} \ + --script={{ health_csp_crawler_node }} diff --git a/roles/health-csp/vars/main.yml b/roles/health-csp/vars/main.yml new file mode 100644 index 00000000..35651865 --- /dev/null +++ b/roles/health-csp/vars/main.yml @@ -0,0 +1,3 @@ +health_csp_crawler_folder: "{{ path_administrator_scripts }}health-csp/" +health_csp_crawler_script: "{{ health_csp_crawler_folder }}health_csp.py" +health_csp_crawler_node: "{{ health_csp_crawler_folder }}health-csp.js" \ No newline at end of file diff --git a/roles/nodejs/README.md b/roles/nodejs/README.md new file mode 100644 index 00000000..ff92a138 --- /dev/null +++ b/roles/nodejs/README.md @@ -0,0 +1,35 @@ +# Node.js + +## Description + +This Ansible role installs Node.js on the target system using the native package manager. + +## Overview + +Optimized for Archlinux and Debian-based systems, this role ensures the presence of Node.js for use in Node-based applications or scripts. It serves as a foundational role for projects that depend on Node.js runtimes or utilities like Puppeteer. + +## Features + +- **Node.js Installation:** Installs the latest Node.js version available via the system's package manager. +- **Idempotent Execution:** Ensures Node.js is only installed when missing. + +## Usage + +Include this role before running any tasks or roles that depend on Node.js: + +```yaml +- name: Ensure Node.js is available + roles: + - nodejs +```` + +## License + +CyMaIS NonCommercial License (CNCL) +[https://s.veen.world/cncl](https://s.veen.world/cncl) + +## Author + +Kevin Veen-Birkenbach +Consulting & Coaching Solutions +[https://www.veen.world](https://www.veen.world) \ No newline at end of file diff --git a/roles/nodejs/meta/main.yml b/roles/nodejs/meta/main.yml new file mode 100644 index 00000000..fdf3bea6 --- /dev/null +++ b/roles/nodejs/meta/main.yml @@ -0,0 +1,24 @@ +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "Installs Node.js" + license: "CyMaIS NonCommercial License (CNCL)" + license_url: "https://s.veen.world/cncl" + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + min_ansible_version: "2.9" + platforms: + - name: Archlinux + versions: [all] + - name: Debian + versions: [all] + galaxy_tags: + - nodejs + - javascript + - runtime + - automation + repository: "https://s.veen.world/cymais" + issue_tracker_url: "https://s.veen.world/cymaisissues" + documentation: "https://s.veen.world/cymais" +dependencies: [] \ No newline at end of file diff --git a/roles/nodejs/tasks/main.yml b/roles/nodejs/tasks/main.yml new file mode 100644 index 00000000..96a6df09 --- /dev/null +++ b/roles/nodejs/tasks/main.yml @@ -0,0 +1,4 @@ +- name: Ensure Node.js is installed + package: + name: nodejs + state: present diff --git a/roles/npm/README.md b/roles/npm/README.md new file mode 100644 index 00000000..5d41d38a --- /dev/null +++ b/roles/npm/README.md @@ -0,0 +1,35 @@ +# npm + +## Description + +This Ansible role installs npm and optionally runs `npm ci` within a given project directory. It is intended to streamline dependency installation for Node.js applications. + +## Overview + +Designed for use in Node-based projects, this role installs npm and can execute a clean install (`npm ci`) to ensure consistent dependency trees. + +## Features + +- **npm Installation:** Ensures the `npm` package manager is installed. +- **Optional Project Setup:** Runs `npm ci` in a specified folder to install exact versions from `package-lock.json`. +- **Idempotent:** Skips `npm ci` if no folder is configured. + +## Configuration + +Set `npm_project_folder` to a directory containing `package.json` and `package-lock.json`: + +```yaml +vars: + npm_project_folder: /opt/scripts/my-node-project/ +``` + +## License + +CyMaIS NonCommercial License (CNCL) +[https://s.veen.world/cncl](https://s.veen.world/cncl) + +## Author + +Kevin Veen-Birkenbach +Consulting & Coaching Solutions +[https://www.veen.world](https://www.veen.world) \ No newline at end of file diff --git a/roles/npm/meta/main.yml b/roles/npm/meta/main.yml new file mode 100644 index 00000000..5dc33b78 --- /dev/null +++ b/roles/npm/meta/main.yml @@ -0,0 +1,24 @@ +galaxy_info: + author: "Kevin Veen-Birkenbach" + description: "Installs npm and runs optional 'npm ci' inside a project" + license: "CyMaIS NonCommercial License (CNCL)" + license_url: "https://s.veen.world/cncl" + company: | + Kevin Veen-Birkenbach + Consulting & Coaching Solutions + https://www.veen.world + min_ansible_version: "2.9" + platforms: + - name: Archlinux + versions: [all] + - name: Debian + versions: [all] + galaxy_tags: + - npm + - nodejs + - automation + - javascript + repository: "https://s.veen.world/cymais" + issue_tracker_url: "https://s.veen.world/cymaisissues" + documentation: "https://s.veen.world/cymais" +dependencies: [] \ No newline at end of file diff --git a/roles/npm/tasks/main.yml b/roles/npm/tasks/main.yml new file mode 100644 index 00000000..e5699931 --- /dev/null +++ b/roles/npm/tasks/main.yml @@ -0,0 +1,12 @@ +- name: Ensure npm is installed + package: + name: npm + state: present + +- name: Run 'npm ci' in {{ npm_project_folder }} + command: npm ci + args: + chdir: "{{ npm_project_folder }}" + when: npm_project_folder is defined + register: npm_output + changed_when: "'added' in npm_output.stdout or 'updated' in npm_output.stdout" \ No newline at end of file diff --git a/roles/npm/vars/main.yml b/roles/npm/vars/main.yml new file mode 100644 index 00000000..e682e89f --- /dev/null +++ b/roles/npm/vars/main.yml @@ -0,0 +1,2 @@ +# Optional project folder to run 'npm ci' in +npm_project_folder: null \ No newline at end of file diff --git a/tests/unit/test_health_csp_py.py b/tests/unit/test_health_csp_py.py new file mode 100644 index 00000000..554a72d4 --- /dev/null +++ b/tests/unit/test_health_csp_py.py @@ -0,0 +1,39 @@ +import unittest +from unittest.mock import patch +import subprocess +import sys +import os + +# 🧩 Add the path to the script under test +SCRIPT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../roles/health-csp/files")) +sys.path.insert(0, SCRIPT_PATH) + +import health_csp + +class TestHealthCspScript(unittest.TestCase): + + @patch("os.listdir") + def test_extract_domains_valid_files(self, mock_listdir): + mock_listdir.return_value = ["example.com.conf", "sub.example.com.conf", "invalid.conf~"] + domains = health_csp.extract_domains("/dummy/path") + self.assertEqual(domains, ["example.com", "sub.example.com"]) + + @patch("os.listdir", side_effect=FileNotFoundError) + def test_extract_domains_missing_dir(self, _): + result = health_csp.extract_domains("/invalid/path") + self.assertIsNone(result) + + @patch("subprocess.run") + def test_run_node_checker_success(self, mock_run): + mock_run.return_value.returncode = 0 + code = health_csp.run_node_checker("/some/script.js", ["example.com"]) + self.assertEqual(code, 0) + + @patch("subprocess.run", side_effect=subprocess.CalledProcessError(3, "node")) + def test_run_node_checker_failure(self, _): + code = health_csp.run_node_checker("/some/script.js", ["fail.com"]) + self.assertEqual(code, 3) + + +if __name__ == "__main__": + unittest.main()