check-opencloud-security 1.2.2__tar.gz → 1.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/CHANGELOG.md +8 -0
  2. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/PKG-INFO +6 -1
  3. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/README.md +5 -0
  4. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/check_opencloud_security.py +42 -0
  5. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/wizard.py +13 -0
  6. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/pyproject.toml +1 -1
  7. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_env_config.py +5 -0
  8. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_webhook.py +67 -1
  9. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/.gitignore +0 -0
  10. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/LICENSE +0 -0
  11. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/ansible/README.md +0 -0
  12. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/contrib/README.md +0 -0
  13. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/docs/README.md +0 -0
  14. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/README.md +0 -0
  15. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/__init__.py +0 -0
  16. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/baseline.py +0 -0
  17. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/cli.py +0 -0
  18. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/completion.py +0 -0
  19. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/config.py +0 -0
  20. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/data/release_schedule.json +0 -0
  21. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/data/vulnerabilities.json +0 -0
  22. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/factory.py +0 -0
  23. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/hardening.py +0 -0
  24. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/releases.py +0 -0
  25. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/scanner.py +0 -0
  26. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/secrets.py +0 -0
  27. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/selfupdate.py +0 -0
  28. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/service.py +0 -0
  29. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/versions.py +0 -0
  30. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/opencloud_local_scan/vulndb.py +0 -0
  31. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/secrets/README.md +0 -0
  32. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/__init__.py +0 -0
  33. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/conftest.py +0 -0
  34. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/fake_opencloud.py +0 -0
  35. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_baseline.py +0 -0
  36. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_completion.py +0 -0
  37. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_concurrency.py +0 -0
  38. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_config.py +0 -0
  39. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_e2e_cli.py +0 -0
  40. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_explain.py +0 -0
  41. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_local_scanner.py +0 -0
  42. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_multi_host.py +0 -0
  43. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_perfdata.py +0 -0
  44. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_rating_thresholds.py +0 -0
  45. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_release_notes.py +0 -0
  46. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_release_schedule.py +0 -0
  47. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_releases.py +0 -0
  48. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_selfupdate.py +0 -0
  49. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_service.py +0 -0
  50. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_update_script.py +0 -0
  51. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_version.py +0 -0
  52. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_vulndb.py +0 -0
  53. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_waivers.py +0 -0
  54. {check_opencloud_security-1.2.2 → check_opencloud_security-1.2.3}/tests/test_wizard.py +0 -0
@@ -12,6 +12,14 @@ entry to `RELEASE.md` and uses it as the body of the GitHub release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [1.2.3] - 2026-08-13
16
+
17
+ ### Security
18
+
19
+ - Block webhook notifications to private, loopback, and link-local addresses
20
+ by default to prevent server-side request forgery. Internal receivers require
21
+ the explicit `--allow-private-webhooks` / `COS_ALLOW_PRIVATE_WEBHOOKS` opt-out.
22
+
15
23
  ## [1.2.2] - 2026-08-13
16
24
 
17
25
  ### Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: check-opencloud-security
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Check the security level of your OpenCloud instance with a built-in scanner - no API access required
5
5
  Author: Massoud Ahmed
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -1194,6 +1194,7 @@ check-opencloud-security --host <Hostname> --check-hardening
1194
1194
  | `--webhook-on` | Lowest state that triggers the webhook (`critical`, `warning`, `unknown`, `always`) | `critical` | `COS_WEBHOOK_ON` |
1195
1195
  | `--webhook-header` | Extra header for the webhook request, repeatable | *None* | `COS_WEBHOOK_HEADERS` |
1196
1196
  | `--webhook-timeout` | HTTP timeout in seconds for the webhook call | `10` | `COS_WEBHOOK_TIMEOUT` |
1197
+ | `--allow-private-webhooks` | Permit webhooks to private, loopback, or link-local addresses | *False* | `COS_ALLOW_PRIVATE_WEBHOOKS` |
1197
1198
  | `--retries` | Retry attempts for transient network errors | `2` | `COS_RETRIES` |
1198
1199
  | `--backoff-factor` | Exponential backoff factor (seconds) between retries | `0.5` | `COS_BACKOFF_FACTOR` |
1199
1200
  | `--config` | Path to the configuration file (`.json` as JSON, else YAML) | auto-discovered | `COS_CONFIG_FILE` |
@@ -1979,6 +1980,10 @@ check-opencloud-security --host opencloud.example.com \
1979
1980
  environment variable: `COS_WEBHOOK_HEADERS="X-Auth-Token: abc; X-Env: prod"`.
1980
1981
  - `--webhook-timeout` / `COS_WEBHOOK_TIMEOUT` (default `10`) limits the
1981
1982
  webhook call; it is independent of the scan `--timeout`.
1983
+ - Webhook destinations that resolve to private, loopback, or link-local
1984
+ addresses are blocked to prevent server-side request forgery. Set
1985
+ `--allow-private-webhooks` or `COS_ALLOW_PRIVATE_WEBHOOKS=true` only for an
1986
+ intentional internal receiver.
1982
1987
 
1983
1988
  Delivery reuses `--retries` / `--backoff-factor`. **A failing webhook never
1984
1989
  changes the check result** - the plugin appends `Webhook delivery failed` to
@@ -505,6 +505,7 @@ check-opencloud-security --host <Hostname> --check-hardening
505
505
  | `--webhook-on` | Lowest state that triggers the webhook (`critical`, `warning`, `unknown`, `always`) | `critical` | `COS_WEBHOOK_ON` |
506
506
  | `--webhook-header` | Extra header for the webhook request, repeatable | *None* | `COS_WEBHOOK_HEADERS` |
507
507
  | `--webhook-timeout` | HTTP timeout in seconds for the webhook call | `10` | `COS_WEBHOOK_TIMEOUT` |
508
+ | `--allow-private-webhooks` | Permit webhooks to private, loopback, or link-local addresses | *False* | `COS_ALLOW_PRIVATE_WEBHOOKS` |
508
509
  | `--retries` | Retry attempts for transient network errors | `2` | `COS_RETRIES` |
509
510
  | `--backoff-factor` | Exponential backoff factor (seconds) between retries | `0.5` | `COS_BACKOFF_FACTOR` |
510
511
  | `--config` | Path to the configuration file (`.json` as JSON, else YAML) | auto-discovered | `COS_CONFIG_FILE` |
@@ -1290,6 +1291,10 @@ check-opencloud-security --host opencloud.example.com \
1290
1291
  environment variable: `COS_WEBHOOK_HEADERS="X-Auth-Token: abc; X-Env: prod"`.
1291
1292
  - `--webhook-timeout` / `COS_WEBHOOK_TIMEOUT` (default `10`) limits the
1292
1293
  webhook call; it is independent of the scan `--timeout`.
1294
+ - Webhook destinations that resolve to private, loopback, or link-local
1295
+ addresses are blocked to prevent server-side request forgery. Set
1296
+ `--allow-private-webhooks` or `COS_ALLOW_PRIVATE_WEBHOOKS=true` only for an
1297
+ intentional internal receiver.
1293
1298
 
1294
1299
  Delivery reuses `--retries` / `--backoff-factor`. **A failing webhook never
1295
1300
  changes the check result** - the plugin appends `Webhook delivery failed` to
@@ -16,6 +16,7 @@ import contextlib
16
16
  import io
17
17
  import ipaddress
18
18
  import logging
19
+ import socket
19
20
  import sys
20
21
  import time
21
22
  from collections import Counter
@@ -24,6 +25,7 @@ from dataclasses import dataclass
24
25
  from datetime import datetime, timezone
25
26
  from enum import IntEnum
26
27
  from typing import Any, NoReturn, TypeVar
28
+ from urllib.parse import urlsplit
27
29
 
28
30
  import requests
29
31
 
@@ -146,6 +148,7 @@ class ScanContext:
146
148
  webhook_url: str | None = None
147
149
  webhook_on: str = DEFAULT_WEBHOOK_ON
148
150
  webhook_timeout: int = DEFAULT_WEBHOOK_TIMEOUT_SECONDS
151
+ allow_private_webhooks: bool = False
149
152
  # Stored as a tuple of pairs so ScanContext stays hashable/frozen.
150
153
  webhook_headers: tuple[tuple[str, str], ...] = ()
151
154
  scanner_settings: ScannerSettings | None = None
@@ -196,6 +199,17 @@ def _env_bool(name: str) -> bool:
196
199
  return _CONFIG.get_bool(name)
197
200
 
198
201
 
202
+ def _allow_private_webhooks_default() -> bool:
203
+ """Read the webhook opt-out from its public environment or nested config key."""
204
+ name = "ALLOW_PRIVATE_WEBHOOKS"
205
+ if any(
206
+ f"{ENV_PREFIX}{suffix}" in _CONFIG.environ
207
+ for suffix in (name, f"{name}_FILE")
208
+ ):
209
+ return _env_bool(name)
210
+ return _env_bool(f"WEBHOOK_{name}")
211
+
212
+
199
213
  def _waiver_patterns(values: list[str] | None) -> tuple[str, ...] | None:
200
214
  """
201
215
  Split repeated --ignore-hardening values into a flat list of patterns.
@@ -633,6 +647,12 @@ def _send_webhook(context: ScanContext, payload: dict[str, Any]) -> bool:
633
647
  url = context.webhook_url
634
648
  if not url:
635
649
  return True
650
+ if not context.allow_private_webhooks and not _is_safe_webhook_url(url):
651
+ LOGGER.warning(
652
+ "Webhook URL points to a restricted private/local IP address and was blocked: %s",
653
+ url,
654
+ )
655
+ return False
636
656
 
637
657
  headers = {"Content-Type": "application/json"}
638
658
  headers.update(dict(context.webhook_headers))
@@ -665,6 +685,18 @@ def _send_webhook(context: ScanContext, payload: dict[str, Any]) -> bool:
665
685
  return True
666
686
 
667
687
 
688
+ def _is_safe_webhook_url(url: str) -> bool:
689
+ """Return whether url resolves to an address that is safe for a webhook."""
690
+ try:
691
+ hostname = urlsplit(url).hostname
692
+ if not hostname:
693
+ return False
694
+ address = ipaddress.ip_address(socket.gethostbyname(hostname))
695
+ except (ValueError, socket.gaierror):
696
+ return False
697
+ return not (address.is_private or address.is_loopback or address.is_link_local)
698
+
699
+
668
700
  def _notify_and_fail(
669
701
  context: ScanContext,
670
702
  message: str,
@@ -1339,6 +1371,15 @@ def build_arg_parser() -> argparse.ArgumentParser:
1339
1371
  f"Default: {DEFAULT_WEBHOOK_TIMEOUT_SECONDS} (env: {ENV_PREFIX}WEBHOOK_TIMEOUT)."
1340
1372
  ),
1341
1373
  )
1374
+ parser.add_argument(
1375
+ "--allow-private-webhooks",
1376
+ action="store_true",
1377
+ default=_allow_private_webhooks_default(),
1378
+ help=(
1379
+ "Allow webhooks to private, loopback, and link-local addresses. "
1380
+ f"Default: False (env: {ENV_PREFIX}ALLOW_PRIVATE_WEBHOOKS)."
1381
+ ),
1382
+ )
1342
1383
  parser.add_argument(
1343
1384
  "--backoff-factor",
1344
1385
  type=float,
@@ -1495,6 +1536,7 @@ def _build_context(host: str, args: argparse.Namespace) -> ScanContext:
1495
1536
  webhook_url=args.webhook_url,
1496
1537
  webhook_on=args.webhook_on,
1497
1538
  webhook_timeout=args.webhook_timeout,
1539
+ allow_private_webhooks=args.allow_private_webhooks,
1498
1540
  webhook_headers=_parse_webhook_headers(args.webhook_header),
1499
1541
  scanner_settings=scanner_settings,
1500
1542
  release_settings=release_settings,
@@ -334,6 +334,19 @@ def optional_groups() -> list[Group]:
334
334
  example="X-Auth-Token: s3cr3t; X-Env: prod",
335
335
  secret=True,
336
336
  ),
337
+ Question(
338
+ key="webhook.allow_private_webhooks",
339
+ prompt="Allow private webhook addresses",
340
+ explain=(
341
+ "Permit delivery to private, loopback, or link-local "
342
+ "addresses. Leave this off unless the receiver is an "
343
+ "intentional internal service."
344
+ ),
345
+ example="no",
346
+ default="no",
347
+ validate=_choice(tuple(YES | NO)),
348
+ cast=lambda value: value.strip().lower() in YES,
349
+ ),
337
350
  ],
338
351
  ),
339
352
  Group(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "check-opencloud-security"
3
- version = "1.2.2"
3
+ version = "1.2.3"
4
4
  description = "Check the security level of your OpenCloud instance with a built-in scanner - no API access required"
5
5
  authors = [{ name = "Massoud Ahmed" }]
6
6
  requires-python = ">=3.10"
@@ -67,6 +67,7 @@ def test_boolean_flags_are_read_from_the_environment(monkeypatch, reload_plugin)
67
67
  monkeypatch.setenv("COS_INSECURE", "yes")
68
68
  monkeypatch.setenv("COS_UPDATE_WARNING", "1")
69
69
  monkeypatch.setenv("COS_NO_DEBUG_PORTS", "on")
70
+ monkeypatch.setenv("COS_ALLOW_PRIVATE_WEBHOOKS", "true")
70
71
 
71
72
  module = reload_plugin()
72
73
  args = module.build_arg_parser().parse_args([])
@@ -75,6 +76,7 @@ def test_boolean_flags_are_read_from_the_environment(monkeypatch, reload_plugin)
75
76
  assert args.insecure is True
76
77
  assert args.update_warning is True
77
78
  assert args.no_debug_ports is True
79
+ assert args.allow_private_webhooks is True
78
80
 
79
81
 
80
82
  @pytest.mark.parametrize("value", ["false", "no", "0", "off", ""])
@@ -161,6 +163,8 @@ def test_config_file_supplies_defaults(monkeypatch, reload_plugin, tmp_path):
161
163
  "host: cloud.example.com\n"
162
164
  "warning: 4\n"
163
165
  "check_hardening: true\n"
166
+ "webhook:\n"
167
+ " allow_private_webhooks: true\n"
164
168
  "scanner:\n"
165
169
  " timeout: 25\n"
166
170
  "releases:\n"
@@ -178,6 +182,7 @@ def test_config_file_supplies_defaults(monkeypatch, reload_plugin, tmp_path):
178
182
  context = module._build_context("cloud.example.com", args)
179
183
  assert context.scanner_settings.timeout == 25
180
184
  assert context.release_settings.mode == "bundled"
185
+ assert context.allow_private_webhooks is True
181
186
 
182
187
 
183
188
  def test_environment_beats_the_config_file(monkeypatch, reload_plugin, tmp_path):
@@ -46,7 +46,7 @@ def posts(monkeypatch):
46
46
 
47
47
  def run(result, **kwargs):
48
48
  """Run the check and return its exit code."""
49
- context = ScanContext(host="cloud.example.com", **kwargs)
49
+ context = ScanContext(host="cloud.example.com", allow_private_webhooks=True, **kwargs)
50
50
  with pytest.raises(SystemExit) as excinfo:
51
51
  plugin.check_vulnerabilities(
52
52
  context, ScanResult(response=result, uuid="local-x"), duration_seconds=1.0
@@ -61,6 +61,11 @@ def test_no_webhook_url_means_no_request(posts):
61
61
  assert posts == []
62
62
 
63
63
 
64
+ def test_private_webhook_protection_is_enabled_by_default():
65
+ """Webhook destinations must be protected unless an operator opts out."""
66
+ assert ScanContext(host="cloud.example.com").allow_private_webhooks is False
67
+
68
+
64
69
  def test_critical_result_fires_the_webhook(posts):
65
70
  """The default trigger is 'critical'."""
66
71
  run(CRITICAL_RESULT, webhook_url="https://hooks.example.com/x")
@@ -172,6 +177,7 @@ def test_webhook_fires_for_an_unreachable_instance(posts, monkeypatch):
172
177
  host="down.example.com",
173
178
  webhook_url="https://x/",
174
179
  webhook_on="unknown",
180
+ allow_private_webhooks=True,
175
181
  scanner_settings=None,
176
182
  )
177
183
 
@@ -196,3 +202,63 @@ def test_webhook_uses_the_configured_proxy(posts):
196
202
  "http": "http://proxy:3128",
197
203
  "https": "http://proxy:3128",
198
204
  }
205
+
206
+
207
+ def test_private_webhook_addresses_are_blocked(monkeypatch, caplog):
208
+ """A webhook must not be usable to reach internal services by default."""
209
+ posted = []
210
+ monkeypatch.setattr(plugin.socket, "gethostbyname", lambda hostname: "127.0.0.1")
211
+ monkeypatch.setattr(
212
+ plugin.requests,
213
+ "post",
214
+ lambda *args, **kwargs: posted.append((args, kwargs)),
215
+ )
216
+
217
+ sent = plugin._send_webhook(
218
+ ScanContext(host="cloud.example.com", webhook_url="https://hooks.example.com/x"),
219
+ {"status": "CRITICAL"},
220
+ )
221
+
222
+ assert sent is False
223
+ assert posted == []
224
+ assert "restricted private/local IP address" in caplog.text
225
+
226
+
227
+ def test_private_webhooks_require_an_explicit_opt_out(monkeypatch):
228
+ """An intentional internal receiver remains available with an opt-out."""
229
+ posted = []
230
+ monkeypatch.setattr(plugin.socket, "gethostbyname", lambda hostname: "127.0.0.1")
231
+
232
+ class _Response:
233
+ def raise_for_status(self):
234
+ pass
235
+
236
+ monkeypatch.setattr(
237
+ plugin.requests,
238
+ "post",
239
+ lambda url, **kwargs: posted.append((url, kwargs)) or _Response(),
240
+ )
241
+
242
+ sent = plugin._send_webhook(
243
+ ScanContext(
244
+ host="cloud.example.com",
245
+ webhook_url="https://hooks.example.com/x",
246
+ allow_private_webhooks=True,
247
+ ),
248
+ {"status": "CRITICAL"},
249
+ )
250
+
251
+ assert sent is True
252
+ assert posted[0][0] == "https://hooks.example.com/x"
253
+
254
+
255
+ @pytest.mark.parametrize("url", ["not a URL", "https://unresolvable.example.com/x"])
256
+ def test_invalid_or_unresolvable_webhook_urls_are_blocked(monkeypatch, url):
257
+ """A malformed or unresolvable destination must fail closed."""
258
+ monkeypatch.setattr(
259
+ plugin.socket,
260
+ "gethostbyname",
261
+ lambda hostname: (_ for _ in ()).throw(plugin.socket.gaierror()),
262
+ )
263
+
264
+ assert plugin._is_safe_webhook_url(url) is False