The plugin has a native Prometheus exporter. Run it with
--prometheus-listen-port 9102 to serve /metrics; it caches a scan for
--scrape-interval seconds (60 by default), so normal scrapes do not trigger
another scan; set it to 0 only when every scrape should. It binds to
127.0.0.1 by default; set --prometheus-listen-addr 0.0.0.0 only when a
firewall or network policy restricts remote scrapers - which is also what a
container needs, alongside publishing the port:
docker run --rm -p 9102:9102 check-opencloud-security \
--host opencloud.example.com --prometheus-listen-port 9102 \
--prometheus-listen-addr 0.0.0.0
For a batch job, --format=prometheus prints one text exposition payload and
exits, and --format=otlp prints the same metrics as the OTLP/JSON body an
OpenTelemetry collector accepts. Every mode requires no extra dependency.
The textfile collector and Pushgateway patterns below remain useful when a scheduled scan is a better fit than a long-running exporter.
If you already run Icinga2, you do not need any of this: the performance data the plugin prints is picked up by Icinga2's Graphite/InfluxDB writers directly.
- Prometheus and Grafana
- The files to copy
- What the exporter publishes
- What there is to graph
- node_exporter textfile collector
- Pushgateway
- OpenTelemetry collector
- Alerting rules
- Grafana
The files to copy
Two of them, both in contrib/, both reading the
metric names the native exporter publishes:
| File | What to do with it |
|---|---|
contrib/prometheus/alerts.yml |
Copy into /etc/prometheus/rules/ and add it to rule_files: |
contrib/grafana/dashboard.json |
Grafana - Dashboards - New - Import, then pick the data source |
cp contrib/prometheus/alerts.yml /etc/prometheus/rules/opencloud-security.yml
promtool check rules /etc/prometheus/rules/opencloud-security.yml
The dashboard’s Instance selector lets one dashboard cover multiple targets. Set alert
delays for the response time you need, and account for how often the underlying scan is
refreshed. Repeated scrapes can contain the same cached scan.
The sections after the next one are the other way to do this: a scheduled
scan whose JSON is reshaped by jq into metric names of your own. Those names
are shorter and deliberately different, and the two shipped files above do not
match them.
What the exporter publishes
| Metric | Labels | Meaning |
|---|---|---|
opencloud_security_rating_score |
host, domain, product, version |
The grade, 0-5, 5 best |
opencloud_security_end_of_life |
host, release_type |
1 once the release receives no more fixes |
opencloud_security_support_days_remaining |
host, release_type |
Days of support left; no sample at all when the end of life is not dated yet |
opencloud_security_vulnerabilities_total |
host, severity |
Advisories matching the reported version |
opencloud_security_hardenings_missing_total |
host |
Missing hardening measures |
opencloud_security_failed_extra_checks_total |
host |
Failed additional checks |
opencloud_security_update_available |
host, target_version |
1 when a newer release exists |
opencloud_security_certificate_days_remaining |
host |
Days until the presented certificate expires; negative once it has, no sample over plain HTTP |
opencloud_security_upgrade_path_complete |
host, target_version |
1 when the recommended upgrade fixes every known advisory; no sample without one |
opencloud_security_waiver_days_remaining |
host |
Days until a --waive-until waiver ends and lets a failing check alert again; no sample when no failing check depends on a deadline |
opencloud_security_coverage_inconclusive_total |
host |
Checks the scan ran and could not decide |
opencloud_security_coverage_not_checked_total |
host |
Checks the scan did not run |
opencloud_security_scan_duration_seconds |
host |
How long the scan took |
opencloud_security_scrape_success |
host |
0 when the scan behind the numbers failed |
A failed scan publishes only the last two. Findings from before the failure
are not re-published, so an instance whose scan is broken has no verdict
rather than a stale one - which is why opencloud_security_scrape_success is
the first thing the dashboard shows.
opencloud_security_end_of_life is a separate family rather than a negative
day count on purpose. A rolling or production release whose end of life has
not been announced reports no days at all, and "unknown" must not read as
"expiring today" in the one alert nobody may miss.
What there is to graph
Every run prints performance data after a |:
rating=5;@0:3;@0:1;0;5 vulnerabilities=0;;;0; time=1.234s;;;0;
| Metric | Meaning |
|---|---|
rating |
0-5, 5 is A+ and 0 is F; U when the scan failed |
vulnerabilities |
Known vulnerabilities for the installed version |
time |
Seconds the scan took |
hardenings_missing |
Missing hardening measures, only with --check-hardening |
extra_checks_failed |
Failed additional checks |
update_available |
1 when a newer release exists |
support_days_left |
Days of support left; negative once overdue |
waiver_days_left |
Days until a temporary waiver ends; absent when none hides a failing check |
coverage_inconclusive |
Checks the scan could not decide |
coverage_not_checked |
Checks the scan did not run |
support_days_left is the one worth alerting on. It goes negative before
anyone notices the instance stopped receiving fixes.
node_exporter textfile collector
The scanner's JSON is easier to consume than the perfdata line, so this
uses check-opencloud-scanner rather than the plugin, and jq to shape it.
Write to a temporary file and rename, or node_exporter will occasionally read
a half-written file.
#!/bin/sh
# /usr/local/bin/opencloud-metrics - run from a systemd timer, see scheduling.md
set -eu
HOST="opencloud.example.com"
OUT="/var/lib/node_exporter/textfile_collector/opencloud_security.prom"
TMP="$(mktemp "${OUT}.XXXXXX")"
check-opencloud-scanner scan --compact "$HOST" > /tmp/opencloud-scan.json || true
jq -r --arg host "$HOST" '
if .error then
"opencloud_scan_success{host=\"\($host)\"} 0"
else
"opencloud_scan_success{host=\"\($host)\"} 1",
"opencloud_security_rating{host=\"\($host)\"} \(.rating)",
"opencloud_end_of_life{host=\"\($host)\"} \(if .EOL then 1 else 0 end)",
"opencloud_vulnerabilities{host=\"\($host)\"} \(.vulnerabilities | length)",
"opencloud_update_available{host=\"\($host)\"} \(if .updates.available then 1 else 0 end)",
"opencloud_support_days_left{host=\"\($host)\"} \(.lifecycle.daysRemaining // 0)",
"opencloud_failed_checks{host=\"\($host)\"} \([.extraChecks[] | select(.passed == false and .ignored == false)] | length)",
"opencloud_version_info{host=\"\($host)\",version=\"\(.version)\",track=\"\(.releaseType)\"} 1"
end' /tmp/opencloud-scan.json > "$TMP"
mv "$TMP" "$OUT"
chmod 644 "$OUT"
opencloud_scan_success is not decoration. Without it a failed scan looks
exactly like a healthy instance, because the other metrics simply keep their
last value until the file is overwritten.
lifecycle.daysRemaining is null for a release whose end of life is not
dated yet - a current rolling or production release, which expires when its
successor ships rather than on a date. The // 0 above turns that into 0;
if that reads as "expiring today" in your alerts, drop the line instead with
select(.lifecycle.daysRemaining != null).
Pushgateway
Same JSON, different sink. Use one grouping key per host so a scan that stops running leaves its last value visible rather than mixing hosts together.
check-opencloud-scanner scan --compact opencloud.example.com \
| jq -r '
"# TYPE opencloud_security_rating gauge",
"opencloud_security_rating \(.rating)",
"# TYPE opencloud_support_days_left gauge",
"opencloud_support_days_left \(.lifecycle.daysRemaining // 0)"' \
| curl -sS --data-binary @- \
http://pushgateway.example.com:9091/metrics/job/opencloud_security/instance/opencloud.example.com
Pushgateway never forgets a metric. Delete the group when you retire an instance, or it will be alerting on a server that no longer exists:
curl -X DELETE http://pushgateway.example.com:9091/metrics/job/opencloud_security/instance/opencloud.example.com
OpenTelemetry collector
--format otlp renders the metrics in this table as one OTLP/JSON
ExportMetricsServiceRequest, which is what a collector accepts at
/v1/metrics over OTLP/HTTP. The plugin prints it; curl posts it, from the
same systemd timer or cron job that already runs the scan:
check-opencloud-security --host opencloud.example.com,other.example.com \
--format otlp \
| curl -sf -X POST http://collector.example.com:4318/v1/metrics \
-H 'Content-Type: application/json' --data-binary @-
The metric names and the host attribute are the exporter's, so a query
written against a scrape works against a collector's output too, and the
alerting rules below need no translation beyond your backend's own label
conventions. Where the collector lives, which proxy reaches it and what
credential it wants are the collector's business and stay in curl's
arguments rather than becoming scanner settings.
A scan that fails still reports: opencloud_security_scrape_success arrives
as 0, with the duration beside it and no findings at all, so an instance
that could not be reached is visible as such instead of keeping the numbers
from the last run that worked.
Alerting rules
For the native exporter, copy
contrib/prometheus/alerts.yml rather
than the block below - it is maintained against the real metric names and
tested against them.
The rules below match the jq-shaped names from the two recipes above,
which are shorter and different:
groups:
- name: opencloud-security
rules:
- alert: OpenCloudEndOfLife
expr: opencloud_end_of_life == 1
for: 1h
labels: {severity: critical}
annotations:
summary: "{{ $labels.host }} runs an OpenCloud release with no security fixes"
- alert: OpenCloudSupportRunningOut
expr: opencloud_support_days_left < 30 and opencloud_support_days_left > 0
for: 6h
labels: {severity: warning}
annotations:
summary: "{{ $labels.host }} loses support in {{ $value }} days"
- alert: OpenCloudRatingDropped
expr: opencloud_security_rating <= 3
for: 1h
labels: {severity: warning}
annotations:
summary: "{{ $labels.host }} is rated {{ $value }}/5"
- alert: OpenCloudScanFailing
# A scan that no longer runs is the failure mode that hides all others.
expr: opencloud_scan_success == 0 or absent(opencloud_scan_success)
for: 2h
labels: {severity: warning}
annotations:
summary: "The OpenCloud security scan has not produced a result"
Prometheus for: measures how long an expression remains true across rule evaluations.
It does not count fresh scans. With daily scan data, for: 5m waits five minutes while
the same cached failure remains visible; it does not wait for a second daily scan.
Grafana
Import contrib/grafana/dashboard.json
and pick your Prometheus data source. It draws the scan-health tile first, the
grade and the lifecycle beside it, then the grade over time, the open
findings, the advisories by severity, and a table of what is running where.
If you would rather build your own: the rating is a 0-5 score where higher
is better, so a stat panel with thresholds at 3 (yellow) and 1 (red)
mirrors the plugin's own defaults. Map the values to the letters the rest of
the output uses: 5 → A+, 4 → A, 3 → C, 2 → D, 1 → E, 0 → F - a
grade with no letter beside it gets read as a score out of five.
Put the version in a table panel next to it. The rating tells you that something is wrong; the version is what tells you whether an upgrade is the answer.