From 93a4dd2f97a129875f75982f78368d72fd89b1b3 Mon Sep 17 00:00:00 2001 From: Username Date: Fri, 26 Dec 2025 16:56:03 +0100 Subject: [PATCH] ci: add security headers audit to pipeline --- .gitea/workflows/ci.yml | 3 + documentation/security-testing-status.md | 2 +- tests/security/headers_audit.py | 108 +++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/security/headers_audit.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e179964..6b69ee7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -191,6 +191,9 @@ jobs: - name: Race condition tests run: python tests/security/race_condition_test.py + - name: Security headers audit + run: python tests/security/headers_audit.py + memory: name: Memory Leak Check runs-on: ubuntu-latest diff --git a/documentation/security-testing-status.md b/documentation/security-testing-status.md index ea06c52..6938fc1 100644 --- a/documentation/security-testing-status.md +++ b/documentation/security-testing-status.md @@ -191,7 +191,7 @@ Not tested (no signature defined): [ ] Add remaining MIME test results to security assessment [ ] Document rate limiting behavior under attack [ ] Create threat model diagram -[ ] Add security headers audit to CI pipeline +[x] Add security headers audit to CI pipeline ``` --- diff --git a/tests/security/headers_audit.py b/tests/security/headers_audit.py new file mode 100644 index 0000000..0a2dc4b --- /dev/null +++ b/tests/security/headers_audit.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Security headers audit for FlaskPaste. + +Verifies all required security headers are present and correctly configured. +""" + +import sys + +sys.path.insert(0, ".") + +from app import create_app + +# Required headers and their expected values +REQUIRED_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Content-Security-Policy": "default-src 'none'", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + "Cache-Control": "no-store, no-cache, must-revalidate", + "Pragma": "no-cache", +} + +# Headers that should NOT be present +FORBIDDEN_HEADERS = [ + "X-Powered-By", + "Server", +] + +# Endpoints to test +TEST_ENDPOINTS = [ + ("/", "GET", 200), + ("/health", "GET", 200), + ("/challenge", "GET", 200), + ("/nonexistent", "GET", 400), # Error response +] + + +def run_audit(): + """Run security headers audit.""" + print("=" * 60) + print("SECURITY HEADERS AUDIT") + print("=" * 60) + + app = create_app("testing") + client = app.test_client() + + results = {"passed": 0, "failed": 0, "warnings": 0} + + for endpoint, method, expected_status in TEST_ENDPOINTS: + print(f"\n[{method} {endpoint}]") + print("-" * 40) + + if method == "GET": + resp = client.get(endpoint) + elif method == "POST": + resp = client.post(endpoint, data=b"test") + else: + continue + + # Check required headers + for header, expected in REQUIRED_HEADERS.items(): + actual = resp.headers.get(header, "") + if expected in actual: + print(f" ✓ {header}") + results["passed"] += 1 + else: + print(f" ✗ {header}") + print(f" Expected: {expected}") + print(f" Got: {actual or '(missing)'}") + results["failed"] += 1 + + # Check forbidden headers + for header in FORBIDDEN_HEADERS: + if header in resp.headers: + print(f" ⚠ {header} should not be present") + results["warnings"] += 1 + else: + print(f" ✓ No {header} header") + results["passed"] += 1 + + # Check X-Request-ID + if "X-Request-ID" in resp.headers: + print(" ✓ X-Request-ID present") + results["passed"] += 1 + else: + print(" ✗ X-Request-ID missing") + results["failed"] += 1 + + # Summary + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + print(f" Passed: {results['passed']}") + print(f" Failed: {results['failed']}") + print(f" Warnings: {results['warnings']}") + + total = results["passed"] + results["failed"] + if results["failed"] == 0: + print(f"\n{results['passed']}/{total} checks passed") + return 0 + else: + print(f"\nFAILED: {results['failed']}/{total} checks failed") + return 1 + + +if __name__ == "__main__": + sys.exit(run_audit())