Added draft for CSP health checker

This commit is contained in:
Kevin Veen-Birkenbach 2025-05-13 09:10:20 +02:00
parent 72baa9ea28
commit 23496f2fab
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
18 changed files with 424 additions and 0 deletions

View File

@ -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_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_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_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_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 on_calendar_health_msmtp: "*-*-* 00:00:00" # Check once per day SMTP Server

View File

@ -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)

View File

View File

@ -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);
})();

View File

@ -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())

View File

@ -0,0 +1,5 @@
- name: "reload health-csp.cymais.service"
systemd:
name: health-csp.cymais.service
enabled: yes
daemon_reload: yes

View File

@ -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 }}"

View File

@ -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 }}"

View File

@ -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 }}

View File

@ -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"

35
roles/nodejs/README.md Normal file
View File

@ -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)

View File

@ -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: []

View File

@ -0,0 +1,4 @@
- name: Ensure Node.js is installed
package:
name: nodejs
state: present

35
roles/npm/README.md Normal file
View File

@ -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)

24
roles/npm/meta/main.yml Normal file
View File

@ -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: []

12
roles/npm/tasks/main.yml Normal file
View File

@ -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"

2
roles/npm/vars/main.yml Normal file
View File

@ -0,0 +1,2 @@
# Optional project folder to run 'npm ci' in
npm_project_folder: null

View File

@ -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()