CLI documentation

OpenCloud security scanner webhook recipes

Connect OpenCloud security results to alerting systems and test them safely.

The webhook posts the plugin's own JSON document by default, including the status and findings. Use --webhook-format to send Slack or Discord messages (see below), or push notifications for ntfy or Gotify. For other receivers, use the generic document and adapt it to the format they expect.

Two rules apply to every recipe here:

  • A failing webhook never changes the check result. The plugin appends Webhook delivery failed and still exits with the state it measured, so a broken notification channel can neither hide nor fake a vulnerable instance.
  • Never put the URL on the command line. It usually is the credential. Use COS_WEBHOOK_URL, or secret:// in the configuration file - see Configuration file and secrets.

The payload, in short

The fields most receivers care about, from the full payload below:

Field Use it for
status, exit_code OK / WARNING / CRITICAL / UNKNOWN and 0-3
message The one-line reason, already written for a human
rating, rating_label The 0-5 score and its A+-F label
host, product_version Which instance, and which release
eol Whether that release still receives security fixes
update.availableVersion What to upgrade to
failed_extra_checks, missing_hardenings The findings themselves
coverage How many checks reached a conclusion, and how many did not
configuration Digests of how the deployment is configured, for spotting drift

A scan that failed outright carries only plugin, plugin_version, timestamp, host, status, exit_code and message. Any receiver that reaches for rating must tolerate its absence.

The full payload

Everything the generic document carries, from a run that found an end-of-life release:

{
  "plugin": "check-opencloud-security",
  "plugin_version": "1.0.0",
  "timestamp": "2026-08-07T10:12:33.123456+00:00",
  "host": "opencloud.example.com",
  "status": "CRITICAL",
  "exit_code": 2,
  "message": "CRITICAL: The 7.3 rolling release line is end-of-life and has no security fixes. Upgrade to 7.4.0.",
  "rating": 0,
  "rating_label": "F",
  "product": "OpenCloud",
  "product_version": "7.3.0",
  "domain": "opencloud.example.com",
  "scanned_at": "2026-08-12 15:24:13.978540",
  "eol": true,
  "release_type": "rolling",
  "lifecycle": {
    "line": "7.3",
    "releaseType": "rolling",
    "state": "endOfLife",
    "released": "2026-07-14",
    "endOfLife": "2026-08-03",
    "daysRemaining": -9,
    "latestOnLine": null,
    "upgradeTo": "7.4.0",
    "reason": "rolling release, unsupported since 2026-08-03",
    "scheduleStale": false,
    "scheduleUpdated": "2026-08-12",
    "scheduleSource": "https://docs.opencloud.eu/docs/admin/resources/lifecycle/",
    "scheduleNote": null
  },
  "eol_warning_days": 30,
  "eol_warning": false,
  "waiver_days_left": 8,
  "waiver_warning_days": 14,
  "waiver_warning": true,
  "upgrade_path": null,
  "upgrade_rehearsal": [],
  "vulnerability_count": 0,
  "vulnerabilities": [],
  "missing_hardenings": [],
  "failed_extra_checks": ["exposed:/opencloud.yaml"],
  "coverage": {"evaluated": 84, "skipped": 6, "indeterminate": 2, "network_limited": 1, "total": 93, "summary": "84 checks evaluated, 6 skipped, 2 indeterminate, 1 network-limited"},
  "configuration": {"digest": "9e3c4428...", "groups": {"tls": "1d0f4b77...:89a97538...", "headers": "b8e1a930...:cb25144c...", "sharing": "44c0ae51...:7b8a1ced...", "authentication": "0a7be2cc...:588d045f...", "proxy": "ff31c084...:b7db6daf..."}},
  "scan_backend": "local",
  "scan_uuid": "6a1d1bd0-...",
  "update": {"available": true, "version": "7.3.0", "availableVersion": "7.4.0", "releasedAt": "2026-08-03", "source": "feed", "error": null, "track": "rolling", "newestRelease": null},
  "duration_seconds": 1.234
}

eol_warning_days is the --eol-warning window the check ran with (0 when off) and eol_warning whether this result is inside it. waiver_days_left, waiver_warning_days and waiver_warning are the same three facts for the next --waive-until deadline; waiver_days_left is null when no failing check depends on one. upgrade_path is the scan's upgradePath - which advisories the recommended release fixes and which it leaves open - or null when there is nothing to clear. upgrade_rehearsal lists every candidate release with what it fixes, leaves (still_affected) and introduces, whether it is end_of_life, and the rating it would reach with its rating_label letter - see Rehearse every upgrade.

scan_backend is always "local" - it records how the result was obtained, so a receiver that also handles payloads from scanners with a remote backend can tell them apart without special-casing the plugin name.

Notifications sent for a failed scan carry only the common fields (plugin, plugin_version, timestamp, host, status, exit_code, message).

A generic receiver

Anything that accepts arbitrary JSON - a log pipeline, a webhook collector, an n8n or Node-RED flow - takes the payload unchanged:

export COS_WEBHOOK_URL='https://collector.example.com/hooks/opencloud'
export COS_WEBHOOK_HEADERS='Authorization: Bearer abc123; X-Env: prod'
check-opencloud-security --host opencloud.example.com --webhook-on warning

--webhook-on decides how much you hear. Each level includes the more severe ones: critical, warning, unknown, always.

Verifying the signature

A webhook URL is usually the only thing standing between an endpoint and anyone who guesses it. --webhook-secret (or COS_WEBHOOK_SECRET) adds a shared-secret signature so a receiver can tell a real notification from an invented one:

export COS_WEBHOOK_SECRET='a-long-random-string'
check-opencloud-security --host opencloud.example.com --webhook-on warning

Every POST then carries

X-COS-Signature: sha256=<hex>

where <hex> is the HMAC-SHA256 of the raw request body, keyed with the secret. The plugin serialises the body once and posts exactly those bytes, so a receiver verifies the bytes it received - it must not re-encode the parsed document first, because any difference in key order or spacing changes the hash.

import hashlib
import hmac

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    """raw_body must be the untouched request body, not a re-serialised dict."""
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header or "")

Use hmac.compare_digest rather than ==; comparing hex digests with a short-circuiting comparison leaks how much of a guess was correct.

Read the raw body with await request.body() in FastAPI or request.get_data() in Flask. Signature verification requires the original bytes, so read the body before parsing it as JSON.

Three things worth knowing:

  • The signature covers whatever was sent, including the chat-native and push documents --webhook-format slack, discord, ntfy and gotify produce. Those services ignore the header; it is there for receivers that check it.
  • No signature header is sent when no secret is set. A receiver that requires one should reject the request rather than treat a missing header as valid.
  • The secret is a credential. Keep it out of the command line the same way the URL is kept out - see Configuration file and secrets.

Uptime Kuma

Uptime Kuma has no plugin system, but its Push monitor is a URL that expects to be called regularly - which is exactly what the webhook does. The check becomes a monitor in three steps.

1. Create the monitor. In Uptime Kuma choose Add New Monitor, monitor type Push, and name it after the instance. Uptime Kuma shows a Push URL of the form https://kuma.example.com/api/push/<token>. Set Heartbeat Interval a little longer than the interval you will run the check at - 300 seconds for a check every four minutes - so a single slow scan does not already count as down.

2. Point the webhook at it, and set --webhook-on always so that a healthy result also reports in. Without it Uptime Kuma would only ever hear from the check when something is wrong, and treat silence as down:

check-opencloud-security --host opencloud.example.com \
  --webhook-url 'https://kuma.example.com/api/push/<token>' \
  --webhook-on always

Or in the configuration file, so the token is not in the process list:

host: opencloud.example.com
webhook:
  url: secret://kuma_push_url
  on: always

3. Run it on a schedule - see systemd timer or cron. Uptime Kuma goes red when no push arrives within the heartbeat interval, so a plugin that cannot run at all shows up as well.

A Push monitor records the status supplied through its own protocol. Do not rely on it to interpret the plugin’s generic JSON as an OpenCloud verdict. To report the measured state, map the plugin result to the Push URL’s status and msg parameters. These payload fields are useful when writing an adapter:

Field in the payload What it tells you in Uptime Kuma
status / exit_code OK, WARNING, CRITICAL or UNKNOWN
message The one-line reason, ready to paste into an alert
rating, rating_label The 0-5 score and its A-F label
product_version, eol Which OpenCloud release, and whether it still gets fixes
update.availableVersion What to upgrade to
duration_seconds How long the scan took

To mark any non-OK plugin result as down, use a wrapper that sends the appropriate Push status:

check-opencloud-security --host opencloud.example.com \
  && curl -fsS 'https://kuma.example.com/api/push/<token>?status=up' \
  || curl -fsS 'https://kuma.example.com/api/push/<token>?status=down&msg=opencloud'

Use the direct heartbeat to detect missing scheduled runs. Use the wrapper or a result-aware adapter when the monitor must also reflect the security check’s status.

Slack, Mattermost, Discord

These expect their own JSON. For the common case, --webhook-format slack or --webhook-format discord posts it directly - no adapter needed:

check-opencloud-security --host opencloud.example.com \
  --webhook-url https://hooks.slack.com/services/... \
  --webhook-format slack

Mattermost accepts the slack format too, and so does the outbound webhook connector in the common Matrix bridge, matrix-hookshot - there is no separate matrix format because none of these has its own distinct webhook contract worth targeting instead. Discord also accepts the slack format at <webhook-url>/slack, if a plain attachment is preferred over an embed.

The adapter below is for anything the built-in formats do not cover - a custom color scheme, extra fields, or a receiver that is almost Slack- or Discord-shaped but not quite:

#!/usr/bin/env python3
"""Forward a check-opencloud-security notification to a Slack-style webhook."""
import json
import os
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer

SLACK_URL = os.environ["SLACK_WEBHOOK_URL"]
COLOURS = {"OK": "#2eb886", "WARNING": "#daa038", "CRITICAL": "#a30200", "UNKNOWN": "#767676"}


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        text = f"*{payload['host']}* - {payload['status']}\n{payload['message']}"
        if payload.get("rating_label"):
            text += f"\nRating {payload['rating_label']}, OpenCloud {payload.get('product_version', '?')}"

        body = json.dumps({
            "attachments": [{
                "color": COLOURS.get(payload["status"], "#767676"),
                "text": text,
            }]
        }).encode()
        request = urllib.request.Request(
            SLACK_URL, data=body, headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(request, timeout=10):
            pass

        self.send_response(204)
        self.end_headers()


HTTPServer(("127.0.0.1", 8099), Handler).serve_forever()

Bind it to localhost and run it next to the check. An adapter that is reachable from elsewhere is an open relay into your chat system.

Discord accepts a compatible payload at <webhook-url>/slack. Mattermost accepts Slack's format directly.

ntfy and Gotify

Both are built in, and neither needs an adapter:

check-opencloud-security --host opencloud.example.com \
  --webhook-url https://ntfy.example.com/opencloud \
  --webhook-format ntfy

check-opencloud-security --host opencloud.example.com \
  --webhook-url https://gotify.example.com/message \
  --webhook-header 'X-Gotify-Key: ...' \
  --webhook-format gotify

For ntfy, give the topic URL. ntfy reads a JSON publication only at its server root, taking the topic from the document rather than from the path, so the plugin reads the topic off the URL you configured and posts to the root of that same server. Scheme, host and port are untouched, so the address checked by the SSRF guard is the address posted to. A URL naming no topic is refused when the check starts, rather than 400-ing on every notification for the life of the configuration. This is the only format whose URL is rewritten, and only ever its path - see ADR 0040.

For Gotify, keep the token out of the URL if you can. ?token=... works and is redacted in the plugin's own logs, but --webhook-header 'X-Gotify-Key: ...' keeps it out of the URL entirely - and out of any proxy log between the two hosts. Either way, --webhook-secret still signs the body, so a receiver that verifies X-COS-Signature can do so here as it does everywhere else.

Priorities follow the state: CRITICAL arrives at ntfy's urgent and Gotify's 8, WARNING at default and 5, UNKNOWN at high and 5, and an OK - which only --webhook-on always ever sends - at the quietest value each service has, so a dead-man's switch does not buzz anybody nightly to say nothing is wrong.

Doing it in a wrapper instead

Worth keeping if you want the plugin's full text rather than its summary, or a priority scheme of your own. This shape also works for any other "notify me if it fails" service:

#!/bin/sh
set -eu
output="$(check-opencloud-security --host opencloud.example.com --check-hardening)" || state=$?
state="${state:-0}"

case "$state" in
  0) exit 0 ;;                       # nothing to say
  1) priority=default ;;
  2) priority=urgent ;;
  *) priority=high ;;
esac

printf '%s' "$output" | curl -sS \
  -H "Title: OpenCloud security check" \
  -H "Priority: $priority" \
  -H "Tags: warning" \
  -d @- https://ntfy.example.com/opencloud

Note || state=$? - the plugin's exit code is the result, and set -e would otherwise abandon the script exactly when there is something to report.

Alertmanager

Alertmanager's v2 API wants a list of alerts, and it wants them to stop arriving before it resolves them:

check-opencloud-security --host opencloud.example.com --webhook-url \
  http://127.0.0.1:8098/  # an adapter that posts to /api/v2/alerts
[{
  "labels": {
    "alertname": "OpenCloudSecurity",
    "instance": "opencloud.example.com",
    "severity": "critical"
  },
  "annotations": {"summary": "<message from the payload>"},
  "startsAt": "<timestamp from the payload>"
}]

Send an alert only for a state you want paged, and let it time out rather than trying to resolve it by hand - the next scan is up to a day away, and an alert resolved early is an alert that silently un-pages a still-vulnerable server. If you already push metrics, Prometheus and Grafana is the better route to Alertmanager.

Testing a receiver without an instance

--webhook-on always plus a host that does not exist produces a real delivery of the failure-shaped payload, which is the case receivers usually get wrong:

check-opencloud-security --host does-not-exist.example.com \
  --webhook-url http://127.0.0.1:8099/ --webhook-on always

For the healthy shape, point it at a real instance you own. --debug logs that a webhook was posted and to where, but not the body - to see the body, point the webhook at something that echoes it, such as python3 -m http.server or a one-line receiver of your own.


Back to the documentation index | Back to the main README

coverage is the scan's own account of what it managed to measure. Every check is in exactly one of evaluated (a conclusion, pass or fail), skipped (the scanner did not run it), indeterminate (it ran and could not tell) and network_limited (nothing answered in time, or there was no route - the gap a retry from elsewhere may close). It is null for a scan document that predates the coverage block, which is a report that does not say rather than a scan with no gaps. Nothing in it changes the status or the rating; the plugin prints the same numbers as a Coverage: line in its output.

configuration is the scan's configuration fingerprint: one digest for the deployment as a whole, and one scope:digest string per group - tls, headers, sharing, authentication, proxy. Store them and compare them with the next notification: a group whose string differs was configured differently, even where the grade did not move. Nothing in it is the configuration itself - a content security policy, an issuer and a server banner all go in and only a hash comes out - so a receiver can keep these next to a ticket without publishing how the instance is set up. The scope half says which facts that group was able to look at, so two scans that probed differently compare as "not comparable" rather than as a change. It is null for a scan document that predates the block, and nothing in it changes the status or the rating.