From d39481807d87bf7b69931bdf00743536df3f6d49 Mon Sep 17 00:00:00 2001 From: Alex Thomae Date: Sun, 6 Dec 2020 17:52:01 +0100 Subject: [PATCH] #5 Add prometheus metrics --- README.md | 37 ++++++++++++++++++++++++++++++-- csp/__main__.py | 4 +++- csp/csp.py | 49 ++++++++++++++++++++++++++++++++++++++----- csp/lib/constants.py | 7 ++++--- csp/lib/prometheus.py | 28 +++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 csp/lib/prometheus.py diff --git a/README.md b/README.md index f2755d9..be56ac9 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,17 @@ services: labels: traefik.enable: 'true' traefik.http.routers.csp.entrypoints: http,https - traefik.http.routers.csp.service: csp@docker - traefik.http.routers.csp.rule: "Host(`csp.example.com`)" + traefik.http.routers.csp.rule: "Host(`csp.example.com`) && Path(`/`)" traefik.http.routers.csp.tls.certResolver: 'default' + traefik.http.routers.csp-metrics.entrypoints: http,https + traefik.http.routers.csp-metrics.rule: "Host(`csp.example.com`) && Path(`/metrics`)" + traefik.http.routers.csp-metrics.middlewares: auth + traefik.http.routers.csp-metrics.tls.certResolver: 'default' traefik.http.services.csp.loadbalancer.server.port: '9180' environment: CSP_PATH: '/' HEALTHZ_PATH: '/health' + ENABLE_METRICS: 'yes' [...] my-website: deploy: @@ -114,14 +118,43 @@ Various errors (with `LOGLEVEL:DEBUG`): 2020-12-06 14:54:07.616 DEBUG [csp.log_csp] Content is not JSON: `{"ab": e2}` ``` +## Metrics + +When setting `ENABLE_METRICS=yes`, the following metrics are exposed: +``` +# HELP csp_valid_violation_reports_total Counts the number of valid violation reports +# TYPE csp_valid_violation_reports_total counter +csp_valid_violation_reports_total{blocked_uri="inline",document_uri="https://xxxREDACTEDxxx/",line_number="925",original_policy="upgrade-insecure-requests; default-src self https://cdnjs.cloudflare.com; script-src self https://cdnjs.cloudflare.com https://s.ytimg.com; font-src https://fonts.gstatic.com https://cdnjs.cloudflare.com; report-uri https://csp.example.com/csp;",violated_directive="script-src-elem"} 3.0 +# HELP csp_valid_violation_reports_created Counts the number of valid violation reports +# TYPE csp_valid_violation_reports_created gauge +csp_valid_violation_reports_created{blocked_uri="inline",document_uri="https://xxxREDACTEDxxx/",line_number="925",original_policy="upgrade-insecure-requests; default-src self https://cdnjs.cloudflare.com; script-src self https://cdnjs.cloudflare.com https://s.ytimg.com; font-src https://fonts.gstatic.com https://cdnjs.cloudflare.com; report-uri https://csp.example.com/csp;",violated_directive="script-src-elem"} 1.607272996845561e+09 +# HELP csp_invalid_violation_reports_total Counts the number of invalid violation reports +# TYPE csp_invalid_violation_reports_total counter +csp_invalid_violation_reports_total{reason="non-csp"} 2.0 +csp_invalid_violation_reports_total{reason="non-json"} 1.0 +csp_invalid_violation_reports_total{reason="empty"} 1.0 +csp_invalid_violation_reports_total{reason="too-large"} 2.0 +# HELP csp_invalid_violation_reports_created Counts the number of invalid violation reports +# TYPE csp_invalid_violation_reports_created gauge +csp_invalid_violation_reports_created{reason="non-csp"} 1.60727299902503e+09 +csp_invalid_violation_reports_created{reason="non-json"} 1.607273003925279e+09 +csp_invalid_violation_reports_created{reason="empty"} 1.607273008638792e+09 +csp_invalid_violation_reports_created{reason="too-large"} 1.607273014508008e+09 +# HELP csp_version_info Information about CSP +# TYPE csp_version_info gauge +csp_version_info{version="0.2.0-225909200"} 1.0 +``` + ## Environment | **Variable** | **Default** | **Description** | |:-------------------------|:-----------:|:-----------------------------------------------------------------------| | `MAX_CONTENT_LENGTH` | `32768` | The maximum content length (in bytes) of the HTTP POST content | | `ENABLE_HEALTHZ_VERSION` | `no` | Set this to `yes` to show the version on the `HEALTHZ_PATH` endpoint | +| `ENABLE_METRICS` | `no` | Set this to `yes` to enable the Prometheus metrics | | `CSP_PATH` | `/csp` | The path used for the CSP reporting | | `HEALTHZ_PATH` | `/healthz` | The path used for the healthcheck | +| `METRICS_PATH` | `/metrics` | The path used for the the Prometheus metrics | | `LOGLEVEL` | `INFO` | [Logging Level](https://docs.python.org/3/library/logging.html#levels) | | `GELF_HOST` | - | If set, GELF UDP logging to this host will be enabled | | `GELF_PORT` | `12201` | Ignored, if `GELF_HOST` is unset. The UDP port for GELF logging | diff --git a/csp/__main__.py b/csp/__main__.py index 23f61ab..f261af7 100644 --- a/csp/__main__.py +++ b/csp/__main__.py @@ -6,6 +6,7 @@ from . import csp from .lib import helpers from .lib import constants +from .lib import prometheus log = logging.getLogger('csp') @@ -15,6 +16,7 @@ 'address': 'string', 'port': 'int', 'enable_healthz_version': 'boolean', + 'enable_metrics': 'boolean', 'csp_path': 'string', 'healthz_path': 'string', 'metrics_path': 'string', @@ -23,5 +25,5 @@ version = f'{constants.VERSION}-{constants.BUILD}' log.warning(f"Starting **{__package__} {version}**. Listening on {c.get_address()}:{c.get_port()}") - +prometheus.PROM_VERSION_INFO.info({'version': f'{version}'}) c.start() diff --git a/csp/csp.py b/csp/csp.py index 6dbf0bd..48db4e7 100644 --- a/csp/csp.py +++ b/csp/csp.py @@ -7,7 +7,8 @@ from flask import Flask from flask import request from waitress import serve -from .lib.constants import VERSION, BUILD, W001, W002, W003 +from .lib.constants import VERSION, BUILD, W001, W002, W003, W004 +from .lib import prometheus log = logging.getLogger('csp') @@ -19,9 +20,10 @@ class CSP(): 'address': '*', 'port': 9180, 'enable_healthz_version': False, + 'enable_metrics': False, 'csp_path': '/csp', 'healthz_path': '/healthz', - 'metrics_path': '/metrics', # Placeholder + 'metrics_path': '/metrics', } def __init__(self, **kwargs): @@ -34,6 +36,8 @@ def __init__(self, **kwargs): self.server.secret_key = os.urandom(64).hex() self.server.add_url_rule(self.settings['csp_path'], 'csp', self.log_csp, methods=['POST']) self.server.add_url_rule(self.settings['healthz_path'], 'healthz', self.healthz, methods=['GET']) + if self.settings['enable_metrics']: + self.server.add_url_rule(self.settings['metrics_path'], 'metrics', self.metrics, methods=['GET', 'POST']) def log_csp(self): """ Logs the content posted """ @@ -46,23 +50,50 @@ def log_csp(self): if request.content_length > self.settings['max_content_length']: log.warning(f'{W001} ({request.content_length}). Dropping.') result = (W001, 413) + prometheus.PROM_INVALID_VIOLATION_REPORTS_COUNTER.labels(reason='too-large').inc(1) except TypeError: log.warning(W002) result = (W002, 422) + prometheus.PROM_INVALID_VIOLATION_REPORTS_COUNTER.labels(reason='empty').inc(1) if result == ('OK', 200): log.debug(f"{request.environ}") content = request.get_data(as_text=True) try: - json_content = json.loads(content) - log.info(f'{json.dumps(json_content)}') - # Placeholder: json_content can now be analysed. Ideally, with a function outside of the CSP class + log.info(self.__process_csp(json.loads(content))) + except CSPError: + log.debug(f'{W004}: `{content}`') + log.warning(W004) + result = (W004, 422) + prometheus.PROM_INVALID_VIOLATION_REPORTS_COUNTER.labels(reason='non-csp').inc(1) except json.decoder.JSONDecodeError: log.debug(f'{W003}: `{content}`') + log.warning(W003) result = (W003, 422) + prometheus.PROM_INVALID_VIOLATION_REPORTS_COUNTER.labels(reason='non-json').inc(1) return result + def __process_csp(self, content): + """ Takes the JSON content and creates the metrics for it """ + try: + report = content['csp-report'] + except KeyError: + raise CSPError from KeyError + + try: + prometheus.PROM_VALID_VIOLATION_REPORTS_COUNTER.labels( + blocked_uri=report['blocked-uri'], + document_uri=report['document-uri'], + original_policy=report['original-policy'], + violated_directive=report['violated-directive'], + line_number=report.get('line-number', 0), + source_file=report.get('source-file'), + ).inc(1) + except KeyError: + raise CSPError from KeyError + return json.dumps(content) + def healthz(self): """ Healthcheck """ version_string = f'{__package__} {VERSION}-{BUILD}' @@ -74,6 +105,10 @@ def healthz(self): message = version_string return (message, 200) + def metrics(self): + """ Prometheus Metrics """ + return prometheus.init() + def start(self): """ Start the web server """ serve( @@ -90,3 +125,7 @@ def get_port(self) -> int: def get_address(self) -> int: """ returns the configured address from self.settings['port'] """ return self.settings['address'] + + +class CSPError(Exception): + """ The CSP Exception resides here """ diff --git a/csp/lib/constants.py b/csp/lib/constants.py index 9da9a88..fc384ba 100644 --- a/csp/lib/constants.py +++ b/csp/lib/constants.py @@ -5,6 +5,7 @@ VERSION = None BUILD = None -W001 = 'Content too large' -W002 = 'Empty content received' -W003 = 'Content is not JSON' +W001 = 'W001: Content too large' +W002 = 'W002: Empty content received' +W003 = 'W003: Content is not JSON' +W004 = 'W004: Received JSON is not a CSP Violation Report' diff --git a/csp/lib/prometheus.py b/csp/lib/prometheus.py new file mode 100644 index 0000000..15ac527 --- /dev/null +++ b/csp/lib/prometheus.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" Initializes the prometheus metrics """ + +from prometheus_client import Counter, Info, make_wsgi_app + +# Prometheus metrics +PROM_VALID_VIOLATION_REPORTS_COUNTER = Counter( + 'csp_valid_violation_reports', 'Counts the number of valid violation reports', [ + 'blocked_uri', + 'document_uri', + 'original_policy', + 'violated_directive', + 'line_number', + 'source_file', + ] +) +PROM_INVALID_VIOLATION_REPORTS_COUNTER = Counter( + 'csp_invalid_violation_reports', 'Counts the number of invalid violation reports', [ + 'reason', + ] +) +PROM_VERSION_INFO = Info('csp_version', 'Information about CSP') + + +def init(): + """ Initializes Prometheus """ + return make_wsgi_app()