From c6f41190d3b130a1ea4728cd356d154416c73569 Mon Sep 17 00:00:00 2001 From: Marius Hein Date: Tue, 10 Nov 2026 08:49:51 +0100 Subject: [PATCH 1/3] feat!: rate versions with the GitLab version check service Tag matching against gitlab-foss failed for every EE instance (revision never matches a foss tag commit), read only the first 20 tags by update date and reported any difference to the newest tag as CRITICAL. - read the installed version from /api/v4/metadata instead of the deprecated /api/v4/version - rate it with version.gitlab.com/check.json, as GitLab's own version badge does: success -> OK, warning -> WARNING, danger or critical_vulnerability -> CRITICAL - print installed version, latest patch release of the installed minor and latest release; no HTML in the output - read the token from CHECK_GITLAB_VERSION_TOKEN, reject tokens with characters invalid in HTTP headers so they never end up in an error message, document a read_user personal access token as least privilege - never follow redirects, cap responses at 1 MiB, --timeout per request (default 10 s instead of 30 s), usage errors exit UNKNOWN (3) - accept a base URL in --host besides a host name - replace requests with urllib from the standard library - test against a local HTTP server, including timeouts, redirects, invalid responses and running the plugin as a process; coverage 100 % - CI: separate lint job, tests on Python 3.9 to 3.15, pylint 4.1.3 BREAKING CHANGE: new output format and states (an available non-security update is WARNING instead of CRITICAL); CHECK_GITLAB_VERSION_URL is replaced by --version-check-url; requests is no longer a dependency. Assisted-by: claude-code:claude-opus-5-5 Signed-off-by: Marius Hein --- .github/workflows/unittest.yml | 64 ++-- .pylintrc | 1 + Makefile | 7 +- README.md | 157 +++++++- check_gitlab_version.py | 339 ++++++++++++++---- requirements-dev.txt | 4 +- requirements.txt | 1 - test_check_gitlab_version.py | 638 +++++++++++++++++++++++++-------- 8 files changed, 944 insertions(+), 267 deletions(-) delete mode 100644 requirements.txt diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index a8e630b..6066f46 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -1,23 +1,41 @@ -name: CI - -on: [push, pull_request] - -jobs: - gitHubActionForPytest: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.9] - name: GitHub Action - steps: - - name: Checkout - uses: actions/checkout@v7 - - name: Install dependencies - run: | - python -m pip install -r requirements-dev.txt -r requirements.txt - - name: Lint - run: | - make lint - - name: Test - run: | - make coverage +name: CI + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + name: Lint + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.14" + - name: Install dependencies + run: | + python -m pip install -r requirements-dev.txt + - name: Lint + run: | + make lint + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] + name: Test (Python ${{ matrix.python-version }}) + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install -r requirements-dev.txt + - name: Test + run: | + make coverage diff --git a/.pylintrc b/.pylintrc index eea2e44..20d9580 100644 --- a/.pylintrc +++ b/.pylintrc @@ -15,6 +15,7 @@ disable=fixme, redefined-outer-name, too-many-instance-attributes, too-many-arguments, + too-many-positional-arguments, too-many-branches, too-many-locals, too-many-statements diff --git a/Makefile b/Makefile index c68d4e2..731b5eb 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,11 @@ -.PHONY: lint test +.PHONY: lint test coverage lint: - python -m pylint check_gitlab_version.py + python -m pylint check_gitlab_version.py test_check_gitlab_version.py test: python -m unittest -v test_check_gitlab_version.py + coverage: python -m coverage run -m unittest -b test_check_gitlab_version.py - python -m coverage report -m --include check_gitlab_version.py + python -m coverage report -m --include check_gitlab_version.py --fail-under 100 diff --git a/README.md b/README.md index ae3a043..45808f1 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,167 @@ # check_gitlab_version -An Icinga check plugin for on premise GitLab version against tags. +An Icinga check plugin that compares the version of a self-managed GitLab +instance against the upstream releases. + +The plugin makes two HTTPS requests: + +1. It reads the installed version from the GitLab API (`/api/v4/metadata`). +2. It sends this version to the GitLab version check service + (`https://version.gitlab.com/check.json`). This is the same service and the same request that + GitLab uses for the version badge on its help page and in the Admin area. + +The rating of the version check service decides the state: + +| State | Meaning | +|----------|------------------------------------------------------------------------------| +| OK | The installed version is up to date. | +| WARNING | A newer version is available, without security relevance. | +| CRITICAL | The installed version is vulnerable, a security update is available. | +| UNKNOWN | A request failed, a response was invalid, or the arguments are wrong. | + +The output names the installed version, the latest patch release of the installed minor version +and the latest release, so the difference is visible at a glance. + +Only the version number (for example `19.5.3`) is sent to `version.gitlab.com`. The token is only +sent to your GitLab instance. ## Installation -Python 3 is required, and you need the Python [requests](https://pypi.org/project/requests/) module. +Python 3.9 or newer is required. The plugin uses only the Python standard library. + + install -m 755 check_gitlab_version.py /usr/lib/nagios/plugins/ + +The monitoring host needs HTTPS access to your GitLab instance and to `version.gitlab.com`. +Proxies are honored through the `https_proxy` and `no_proxy` environment variables. + +## Access token + +The GitLab API returns the version only to authenticated users, so the plugin needs a token. +Use the token with the least privileges: -Please prefer installation via system packages like `python3-requests`. +* Create a dedicated user, for example `monitoring`, as a regular user, not as an administrator, + and without any group or project memberships. +* Create a personal access token for this user with only the `read_user` scope. -Alternatively you can install with pip: +This token can read the profile of the user and public user information, but no projects, +groups or repositories. - pip3 install -r requirements.txt +Project and group access tokens are not recommended: they cannot have the `read_user` scope and need +`read_api`, which grants read access to every project the token's bot user can see. + +Personal access tokens expire. Monitor the expiry date, or renew the token in time. ## Usage -The check plugins uses the Git tags from gitlab.com to retrieve the current version. The URL can be adjusted using the environment variable `CHECK_GITLAB_VERSION_URL`. +``` +usage: check_gitlab_version.py [-h] [-V] -H HOST [-t TOKEN] + [--version-check-url VERSION_CHECK_URL] + [--timeout TIMEOUT] + +options: + -h, --help show this help message and exit + -V, --version show program's version number and exit + -H, --host HOST GitLab host name (uses https://) or base URL, + e.g. https://example.com/gitlab + -t, --token TOKEN GitLab personal access token with the read_user scope + (default: $CHECK_GITLAB_VERSION_TOKEN) + --version-check-url VERSION_CHECK_URL + URL of the GitLab version check service (default: + https://version.gitlab.com/check.json) + --timeout TIMEOUT timeout in seconds for each of the two HTTP requests + (default: 10) +``` + +Pass the token in the environment variable `CHECK_GITLAB_VERSION_TOKEN`. A token passed with +`--token` is visible in the process list of the monitoring host. + +The plugin never follows HTTP redirects, so the token cannot be sent to a redirect target. +Use the final URL of your GitLab instance. + +### Examples + +``` +$ CHECK_GITLAB_VERSION_TOKEN=... check_gitlab_version.py --host gitlab.example.com +[OK] GitLab 19.5.3 is up to date (latest release: 19.5.3) + \_ installed version: 19.5.3 (Community Edition) + \_ latest patch release of 19.5: 19.5.3 + \_ latest release: 19.5.3 + \_ critical vulnerability: no +``` + +``` +[WARNING] GitLab 19.4.4 is outdated, update available: 19.4.5 (latest patch release of 19.4), 19.5.3 (latest release) + \_ installed version: 19.4.4 (Community Edition) + \_ latest patch release of 19.4: 19.4.5 + \_ latest release: 19.5.3 + \_ newer maintained releases: 19.5.3, 19.4.5 + \_ critical vulnerability: no +``` + +``` +[CRITICAL] GitLab 19.3.1-ee is affected by a critical vulnerability, update now: 19.3.7 (latest patch release of 19.3), 19.5.3 (latest release) + \_ installed version: 19.3.1-ee (Enterprise Edition) + \_ latest patch release of 19.3: 19.3.7 + \_ latest release: 19.5.3 + \_ newer maintained releases: 19.5.3, 19.4.5, 19.3.7 + \_ critical vulnerability: yes +``` + +``` +[UNKNOWN] https://gitlab.example.com/api/v4/metadata returned HTTP 401: 401 Unauthorized +``` + +## Icinga 2 ``` -check_gitlab_version.py' '--host' 'git.netways.de' '--token' 'XXX-API-TOKEN-XXX' -[OK] - GitLab Version Status - premise=13.9.3, gitlab=13.9.3 +object CheckCommand "gitlab_version" { + command = [ PluginDir + "/check_gitlab_version.py" ] + + arguments = { + "--host" = { + value = "$gitlab_version_host$" + required = true + } + "--timeout" = "$gitlab_version_timeout$" + } + + env = { + CHECK_GITLAB_VERSION_TOKEN = "$gitlab_version_token$" + } +} + +apply Service "gitlab-version" { + check_command = "gitlab_version" + check_interval = 1h + retry_interval = 15m + + vars.gitlab_version_host = host.vars.gitlab_host + + assign where host.vars.gitlab_host +} ``` +GitLab publishes releases at most a few times a week, so an hourly check is enough. +The Icinga Director does not support `env` on commands. There, pass the token with `--token`. + +## Upgrading from 0.1.0 + +* The state comes from the GitLab version check service instead of the Git tags on gitlab.com. + An installed version that is not the latest release is now WARNING, and CRITICAL only if it is + vulnerable. Before, every difference was CRITICAL. +* Enterprise Edition instances work. Before, they always ended as UNKNOWN. +* The output format changed and no longer contains HTML. +* The environment variable `CHECK_GITLAB_VERSION_URL` is replaced by `--version-check-url`. +* The token can be passed in `CHECK_GITLAB_VERSION_TOKEN`, and `read_user` is enough as scope. +* The `requests` module is no longer needed. + +## Development + + python -m pip install -r requirements-dev.txt + make lint + make test + make coverage + ## License ``` diff --git a/check_gitlab_version.py b/check_gitlab_version.py index 4a782a8..1d93e4f 100755 --- a/check_gitlab_version.py +++ b/check_gitlab_version.py @@ -22,110 +22,297 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import argparse -import sys -import os +"""Icinga check plugin that compares the version of a self-managed GitLab +instance against the upstream releases. -import requests +The installed version is read from the GitLab API (/api/v4/metadata). It is +then rated by the GitLab version check service (version.gitlab.com), the same +service GitLab uses for the version badge in its Admin area. +""" -__version__ = '0.1.0' +from __future__ import annotations -GITLAB_URL = os.getenv("CHECK_GITLAB_VERSION_URL", default="https://gitlab.com/api/v4/projects/13083/repository/tags") - - -def semver(version): - # Normalize version string. v16.1.3 -> 16.1.3 - version = version.lstrip("v").replace("-", "").split('.') - # In case we get a 16.0 - if len(version) < 3: - version.append('0') +import argparse +import base64 +import http.client +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass - major, minor, patch = map(int, version) +__version__ = "0.2.0" - return major, minor, patch +OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3 +STATE_NAMES = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"} +DEFAULT_VERSION_CHECK_URL = "https://version.gitlab.com/check.json" +DEFAULT_TIMEOUT = 10 +TOKEN_ENV = "CHECK_GITLAB_VERSION_TOKEN" +USER_AGENT = f"check_gitlab_version/{__version__}" +MAX_RESPONSE_BYTES = 1024 * 1024 +MAX_DETAIL_LENGTH = 200 -def commandline(args): +# 19.5.3, v19.5.3, 19.5.3-ee, 19.6.0-pre, 19.5.0-rc42 +VERSION_PATTERN = re.compile(r"v?(?P\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?") - parser = argparse.ArgumentParser(prog="check_gitlab_version.py") +# Printable ASCII, as GitLab tokens are +TOKEN_PATTERN = re.compile(r"[\x21-\x7e]+") - parser.add_argument('-V', '--version', action='version', version=__version__) - parser.add_argument('-H', '--host', type=str, required=True, - help='GitLab Hostname (Premise)') - parser.add_argument('-t', '--token', type=str, required=True, - help='GitLab Token (Premise)') +SEVERITIES = ("success", "warning", "danger") - return parser.parse_args(args) +class CheckError(Exception): + """An error that makes the check result UNKNOWN.""" -def return_plugin(status, msg): - states = { - 0: "OK", - 1: "WARNING", - 2: "CRITICAL", - 3: "UNKNOWN" - } - print("[{0}] GitLab Version Status - {1}".format(states.get(status, "UNKNOWN"), msg)) - return status +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + # Redirects are never followed: urllib would send the token header to the + # redirect target, and neither API is expected to redirect. + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None -def main(args): - tags = requests.get(GITLAB_URL, timeout=30) +_OPENER = urllib.request.build_opener(_NoRedirectHandler) - if tags.status_code != 200: - return return_plugin( - 3, - "GitLab check failed - http={0}, text={1}".format( - tags.status_code, tags.text - ), - ) - versions = [] - commits = {} +@dataclass(frozen=True) +class Installed: + version: str + release: str + enterprise: bool | None - for tag in tags.json(): - # Skip release candidates - if "rc" in tag["name"]: - continue + @property + def edition(self) -> str: + if self.enterprise is None: + return "" + return "Enterprise Edition" if self.enterprise else "Community Edition" - version = tag["name"] - versions.append(version) - commits[tag["commit"]["id"][0:11]] = version +@dataclass(frozen=True) +class Upstream: + severity: str + critical_vulnerability: bool + latest_version: str + latest_of_minor: str | None + newer_versions: list[str] - versions.sort(key=semver) - premise_headers = {"PRIVATE-TOKEN": args.token} +class _ArgumentParser(argparse.ArgumentParser): + # Usage errors must result in UNKNOWN with the state in the first line; + # argparse exits with 2 (CRITICAL). + def error(self, message): + print(f"[UNKNOWN] {self.prog}: error: {message}") + self.print_usage(sys.stdout) + sys.exit(UNKNOWN) - check = requests.get("https://{0}/api/v4/version".format(args.host), headers=premise_headers, timeout=30) - if check.status_code != 200: - return return_plugin( - 3, - "Premise check failed - http={0}, text={1}".format( - check.status_code, check.text - ), - ) - premise = check.json() +def _positive_int(value: str) -> int: + try: + number = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid integer: {value!r}") from None + if number < 1: + raise argparse.ArgumentTypeError(f"must be at least 1: {value!r}") + return number + + +def commandline(args: list[str]) -> argparse.Namespace: + parser = _ArgumentParser( + prog="check_gitlab_version.py", + description="Compare the version of a self-managed GitLab instance against the upstream releases.", + epilog=f"Prefer the environment variable {TOKEN_ENV} over --token, " + "it keeps the token out of the process list.", + ) + + parser.add_argument("-V", "--version", action="version", version=__version__) + parser.add_argument("-H", "--host", required=True, + help="GitLab host name (uses https://) or base URL, e.g. https://example.com/gitlab") + parser.add_argument("-t", "--token", default=os.environ.get(TOKEN_ENV), + help=f"GitLab personal access token with the read_user scope (default: ${TOKEN_ENV})") + parser.add_argument("--version-check-url", default=DEFAULT_VERSION_CHECK_URL, + help="URL of the GitLab version check service (default: %(default)s)") + parser.add_argument("--timeout", type=_positive_int, default=DEFAULT_TIMEOUT, + help="timeout in seconds for each of the two HTTP requests (default: %(default)s)") + + parsed = parser.parse_args(args) + parsed.token = (parsed.token or "").strip() + + if not parsed.token: + parser.error(f"a token is required, set {TOKEN_ENV} or use --token") + # http.client rejects such header values with an error message that + # contains the value, which would print the token. + if not TOKEN_PATTERN.fullmatch(parsed.token): + parser.error("the token contains characters that are not allowed in an HTTP header") + + return parsed + + +def base_url(host: str) -> str: + if "://" in host: + return host.rstrip("/") + return f"https://{host.strip('/')}" + + +def _error_detail(body: bytes) -> str: + try: + data = json.loads(body) + except ValueError: + return "" + if not isinstance(data, dict): + return "" + for key in ("message", "error_description", "error"): + value = data.get(key) + if isinstance(value, str) and value: + return value[:MAX_DETAIL_LENGTH] + return "" + + +def fetch_json(url: str, headers: dict[str, str], timeout: int) -> object: + request = urllib.request.Request( + url, headers={"Accept": "application/json", "User-Agent": USER_AGENT, **headers} + ) + + # Messages name the URL without its query string + name = url.split("?", 1)[0] try: - premise_version = commits[premise["revision"]] - current_version = versions.pop() + with _OPENER.open(request, timeout=timeout) as response: + body = response.read(MAX_RESPONSE_BYTES + 1) + except urllib.error.HTTPError as error: + with error: + if 300 <= error.code < 400: + location = error.headers.get("Location", "an unknown location") + raise CheckError(f"{name} redirects to {location} (HTTP {error.code}), " + "redirects are not followed") from None + detail = _error_detail(error.read(MAX_RESPONSE_BYTES)) + raise CheckError(f"{name} returned HTTP {error.code}" + (f": {detail}" if detail else "")) from None + except urllib.error.URLError as error: + raise CheckError(f"cannot connect to {name}: {error.reason}") from None + except (OSError, http.client.HTTPException) as error: + # Timeouts and broken connections while reading the response + raise CheckError(f"request to {name} failed: {str(error) or type(error).__name__}") from None + + if len(body) > MAX_RESPONSE_BYTES: + raise CheckError(f"response from {name} exceeds {MAX_RESPONSE_BYTES} bytes") - if premise_version == current_version: - return return_plugin(0,"Version is UpToDate - premise={0}, gitlab={1}".format(premise_version, current_version),) + try: + return json.loads(body) + except ValueError: + raise CheckError(f"response from {name} is not valid JSON") from None + + +def parse_version(version: str) -> str: + """Return the release part of a GitLab version, e.g. 19.5.3 for 19.5.3-ee.""" + match = VERSION_PATTERN.fullmatch(version) + if not match: + raise CheckError(f"cannot parse GitLab version {version!r}") + return match.group("release") + + +def get_installed(url: str, token: str, timeout: int) -> Installed: + data = fetch_json(f"{url}/api/v4/metadata", {"PRIVATE-TOKEN": token}, timeout) + + if not isinstance(data, dict) or not isinstance(data.get("version"), str): + raise CheckError(f"no version in the response from {url}/api/v4/metadata") + + enterprise = data.get("enterprise") + return Installed( + version=data["version"], + release=parse_version(data["version"]), + enterprise=enterprise if isinstance(enterprise, bool) else None, + ) + + +def get_upstream(check_url: str, release: str, timeout: int) -> Upstream: + # Same request as the version check of GitLab itself (VersionCheckCronWorker). + # The plain release is sent: version.gitlab.com mis-rates suffixes like -ee. + gitlab_info = base64.urlsafe_b64encode(json.dumps({"version": release}).encode()).decode() + separator = "&" if urllib.parse.urlsplit(check_url).query else "?" + data = fetch_json(f"{check_url}{separator}{urllib.parse.urlencode({'gitlab_info': gitlab_info})}", {}, timeout) + + if not isinstance(data, dict): + raise CheckError(f"unexpected response from {check_url}") + + severity = data.get("severity") + latest_version = data.get("latest_version") + latest_of_minor = data.get("latest_stable_version_of_minor") + newer_versions = data.get("latest_stable_versions") or [] + + if severity not in SEVERITIES: + raise CheckError(f"unknown severity {severity!r} in the response from {check_url}") + if not isinstance(latest_version, str) or not latest_version: + raise CheckError(f"no latest version in the response from {check_url}") + if latest_of_minor is not None and not isinstance(latest_of_minor, str): + raise CheckError(f"invalid latest_stable_version_of_minor in the response from {check_url}") + if not isinstance(newer_versions, list) or not all(isinstance(v, str) for v in newer_versions): + raise CheckError(f"invalid latest_stable_versions in the response from {check_url}") + + return Upstream( + severity=severity, + # GitLab itself reads this field with to_boolean + critical_vulnerability=data.get("critical_vulnerability") in (True, "true"), + latest_version=latest_version, + latest_of_minor=latest_of_minor or None, + newer_versions=newer_versions, + ) + + +def evaluate(installed: Installed, upstream: Upstream) -> tuple[int, str]: + minor = installed.release.rsplit(".", 1)[0] + + if upstream.critical_vulnerability: + state, summary = CRITICAL, f"GitLab {installed.version} is affected by a critical vulnerability, update now" + elif upstream.severity == "danger": + state, summary = CRITICAL, f"GitLab {installed.version} is vulnerable, security update required" + elif upstream.severity == "warning": + state, summary = WARNING, f"GitLab {installed.version} is outdated, update available" + else: + state, summary = OK, f"GitLab {installed.version} is up to date" + + if state == OK: + summary += f" (latest release: {upstream.latest_version})" + else: + targets = [] + if upstream.latest_of_minor and upstream.latest_of_minor != installed.release: + targets.append(f"{upstream.latest_of_minor} (latest patch release of {minor})") + if upstream.latest_version not in (installed.release, upstream.latest_of_minor): + targets.append(f"{upstream.latest_version} (latest release)") + if targets: + summary += ": " + ", ".join(targets) + + details = [f"installed version: {installed.version}" + (f" ({installed.edition})" if installed.edition else "")] + if upstream.latest_of_minor: + details.append(f"latest patch release of {minor}: {upstream.latest_of_minor}") + details.append(f"latest release: {upstream.latest_version}") + if upstream.newer_versions: + details.append(f"newer maintained releases: {', '.join(upstream.newer_versions)}") + details.append(f"critical vulnerability: {'yes' if upstream.critical_vulnerability else 'no'}") + + return state, summary + "\n" + "\n".join(f" \\_ {line}" for line in details) + + +def output(state: int, message: str) -> int: + print(f"[{STATE_NAMES[state]}] {message}") + return state + + +def main(args: argparse.Namespace) -> int: + try: + installed = get_installed(base_url(args.host), args.token, args.timeout) + upstream = get_upstream(args.version_check_url, installed.release, args.timeout) + except CheckError as error: + return output(UNKNOWN, str(error)) - return return_plugin(2, "Version Mismatch - premise={0}, gitlab={1}".format(premise_version, current_version),) - except KeyError: - return return_plugin(3, "Error get version from data") + return output(*evaluate(installed, upstream)) -if __package__ == '__main__' or __package__ is None: # pragma: no cover +if __name__ == "__main__": # pragma: no cover try: - ARGS = commandline(sys.argv[1:]) - sys.exit(main(ARGS)) - except Exception: # pylint: disable=broad-except - exception = sys.exc_info() - print("[UNKNOWN] Unexpected Python error: %s %s" % (exception[0], exception[1])) - sys.exit(3) + sys.exit(main(commandline(sys.argv[1:]))) + except Exception as error: # pylint: disable=broad-except + print(f"[UNKNOWN] Unexpected error: {type(error).__name__}: {error}") + sys.exit(UNKNOWN) diff --git a/requirements-dev.txt b/requirements-dev.txt index 5f87bf9..78c8caa 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,2 @@ -pylint==2.15.0 -coverage==7.0.5 +pylint==4.1.3; python_version >= "3.10" +coverage==7.10.7 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index cf56d5b..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests>=2 diff --git a/test_check_gitlab_version.py b/test_check_gitlab_version.py index 1893c19..bda5f84 100644 --- a/test_check_gitlab_version.py +++ b/test_check_gitlab_version.py @@ -1,161 +1,491 @@ #!/usr/bin/env python3 -import unittest -import unittest.mock as mock -import sys +import base64 +import contextlib +import io import json - -sys.path.append('..') - - -from check_gitlab_version import return_plugin -from check_gitlab_version import commandline -from check_gitlab_version import semver -from check_gitlab_version import main - - -class MockRequest(): - def __init__(self, status_code, text): - self.status_code = status_code - self.text = text - def json(self): - return json.loads(self.text) - - -class CLITesting(unittest.TestCase): - - def test_commandline(self): - actual = commandline(['-H', 'localhost', '--token', 'foobar']) - self.assertEqual(actual.host, 'localhost') - self.assertEqual(actual.token, 'foobar') - -class UtilTesting(unittest.TestCase): - - @mock.patch('builtins.print') - def test_return_plugin(self, mock_print): - actual = return_plugin(1, 'foobar') - self.assertEqual(actual, 1) - - mock_print.assert_called_with('[WARNING] GitLab Version Status - foobar') - - @mock.patch('builtins.print') - def test_return_plugin_with_invalid_code(self, mock_print): - actual = return_plugin(4, 'oh no!') - self.assertEqual(actual, 4) - - mock_print.assert_called_with('[UNKNOWN] GitLab Version Status - oh no!') - - - def test_semver_sort(self): - - versions = ['v16.2.4', 'v16.1.4', 'v16.2.3', 'v16.2.2', 'v16.0.8', 'v16.1.3', 'v15.11.13', 'v16.2.1', 'v16.2.0', 'v15.11.12', 'v15.11.11', 'v16.0.7', 'v16.1.2', 'v15.11.10', 'v16.0.6', 'v16.1.1'] - - expected = ['v15.11.10', 'v15.11.11', 'v15.11.12', 'v15.11.13', 'v16.0.6', 'v16.0.7', 'v16.0.8', 'v16.1.1', 'v16.1.2', 'v16.1.3', 'v16.1.4', 'v16.2.0', 'v16.2.1', 'v16.2.2', 'v16.2.3', 'v16.2.4'] - - actual = sorted(versions, key=semver) - - self.assertEqual(actual, expected) - - versions = ['v16.2.4', 'v15.11.10', 'v16.0', 'v16.1.1'] - - expected = ['v15.11.10', 'v16.0', 'v16.1.1', 'v16.2.4'] - - actual = sorted(versions, key=semver) - - self.assertEqual(actual, expected) +import os +import socketserver +import subprocess +import sys +import threading +import time +import unittest +from unittest import mock +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import check_gitlab_version as plugin + +PLUGIN = Path(__file__).resolve().parent / "check_gitlab_version.py" +TOKEN = "glpat-test-token-do-not-print" + + +def upstream_response(severity="success", latest="19.5.3", minor="19.5.3", newer=None, critical=False): + return { + "latest_stable_versions": newer or [], + "latest_stable_version_of_minor": minor, + "latest_version": latest, + "severity": severity, + "critical_vulnerability": critical, + "details": "", + } + + +class _LocalHTTPServer(ThreadingHTTPServer): + daemon_threads = True + + def server_bind(self): + # HTTPServer.server_bind() resolves the FQDN of the host, which stalls + # the tests for seconds where reverse DNS is slow. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + +class FakeServer: + """Local HTTP server that plays both the GitLab instance and version.gitlab.com.""" + + def __init__(self): + self.routes = {} + self.requests = [] + server = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): # pylint: disable=invalid-name + parsed = urllib.parse.urlsplit(self.path) + server.requests.append((parsed.path, parsed.query, self.headers)) + status, headers, body, delay = server.routes.get(parsed.path, (404, {}, b'{"message":"404 Not Found"}', 0)) + if delay: + time.sleep(delay) + self.send_response(status) + for key, value in headers.items(): + self.send_header(key, value) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): # pylint: disable=redefined-builtin + pass + + self.httpd = _LocalHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.httpd.server_port}" + self.thread = threading.Thread(target=self.httpd.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True) + + def route(self, path, status=200, body=None, headers=None, delay=0): + if isinstance(body, (dict, list)): + body = json.dumps(body).encode() + self.routes[path] = (status, headers or {"Content-Type": "application/json"}, body or b"", delay) + + def metadata(self, version="19.5.3", enterprise=False): + self.route("/api/v4/metadata", body={"version": version, "revision": "0d4c9a9a2fa", "enterprise": enterprise}) + + def upstream(self, **kwargs): + self.route("/check.json", body=upstream_response(**kwargs)) + + def sent_version(self): + query = next(q for p, q, _ in self.requests if p == "/check.json") + info = urllib.parse.parse_qs(query)["gitlab_info"][0] + return json.loads(base64.urlsafe_b64decode(info)) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *exc): + self.httpd.shutdown() + self.httpd.server_close() + + +def run_main(server, *extra): + args = plugin.commandline(["-H", server.url, "-t", TOKEN, + "--version-check-url", f"{server.url}/check.json", "--timeout", "2", *extra]) + out = io.StringIO() + with contextlib.redirect_stdout(out): + state = plugin.main(args) + return state, out.getvalue() + + +class CommandlineTesting(unittest.TestCase): + + def test_arguments(self): + args = plugin.commandline(["-H", "git.example.com", "--token", "secret", "--timeout", "5"]) + self.assertEqual(args.host, "git.example.com") + self.assertEqual(args.token, "secret") + self.assertEqual(args.timeout, 5) + self.assertEqual(args.version_check_url, "https://version.gitlab.com/check.json") + + def test_token_from_environment(self): + with mock.patch.dict(os.environ, {plugin.TOKEN_ENV: "from-env"}): + args = plugin.commandline(["-H", "git.example.com"]) + self.assertEqual(args.token, "from-env") + + def test_token_argument_overrides_environment(self): + with mock.patch.dict(os.environ, {plugin.TOKEN_ENV: "from-env"}): + args = plugin.commandline(["-H", "git.example.com", "-t", "from-arg"]) + self.assertEqual(args.token, "from-arg") + + def test_usage_errors_are_unknown(self): + cases = [ + [], + ["-H", "git.example.com"], + ["-H", "git.example.com", "-t", ""], + ["-H", "git.example.com", "-t", "x", "--timeout", "0"], + ["-H", "git.example.com", "-t", "x", "--timeout", "abc"], + ["--unknown"], + ] + for argv in cases: + with self.subTest(argv=argv), mock.patch.dict(os.environ, clear=True): + out = io.StringIO() + with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as ctx: + plugin.commandline(argv) + self.assertEqual(ctx.exception.code, 3) + self.assertTrue(out.getvalue().startswith("[UNKNOWN] "), out.getvalue()) + + def test_token_is_stripped(self): + args = plugin.commandline(["-H", "git.example.com", "-t", " secret\n"]) + self.assertEqual(args.token, "secret") + + def test_token_with_control_characters_is_not_printed(self): + for token in ["glpat-se\ncret", "glpat-se\rcret", "glpat-\x00secret", "glpat-säcret"]: + with self.subTest(token=token): + out = io.StringIO() + with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as ctx: + plugin.commandline(["-H", "git.example.com", "-t", token]) + self.assertEqual(ctx.exception.code, 3) + self.assertTrue(out.getvalue().startswith("[UNKNOWN] ")) + self.assertNotIn("cret", out.getvalue()) + + def test_base_url(self): + self.assertEqual(plugin.base_url("git.example.com"), "https://git.example.com") + self.assertEqual(plugin.base_url("git.example.com:8443"), "https://git.example.com:8443") + self.assertEqual(plugin.base_url("https://example.com/gitlab/"), "https://example.com/gitlab") + self.assertEqual(plugin.base_url("http://127.0.0.1:8080"), "http://127.0.0.1:8080") + + +class VersionTesting(unittest.TestCase): + + def test_parse_version(self): + cases = { + "19.5.3": "19.5.3", + "v19.5.3": "19.5.3", + "19.5.3-ee": "19.5.3", + "19.6.0-pre": "19.6.0", + "19.5.0-rc42": "19.5.0", + "18.11.14": "18.11.14", + } + for version, expected in cases.items(): + with self.subTest(version=version): + self.assertEqual(plugin.parse_version(version), expected) + + def test_parse_invalid_version(self): + for version in ["", "19.5", "19", "garbage", "19.5.3 ", "19.5.x", "19.5.3-", "19.5.3\n", "19.5.3-ee\nfoo"]: + with self.subTest(version=version), self.assertRaises(plugin.CheckError): + plugin.parse_version(version) + + +class EvaluateTesting(unittest.TestCase): + + @staticmethod + def installed(version="19.5.3", enterprise=False): + return plugin.Installed(version=version, release=plugin.parse_version(version), enterprise=enterprise) + + @staticmethod + def upstream(**kwargs): + data = upstream_response(**kwargs) + return plugin.Upstream( + severity=data["severity"], + critical_vulnerability=data["critical_vulnerability"], + latest_version=data["latest_version"], + latest_of_minor=data["latest_stable_version_of_minor"], + newer_versions=data["latest_stable_versions"], + ) + + def test_up_to_date(self): + state, text = plugin.evaluate(self.installed(), self.upstream()) + self.assertEqual(state, plugin.OK) + self.assertEqual(text.splitlines()[0], "GitLab 19.5.3 is up to date (latest release: 19.5.3)") + self.assertIn(" \\_ installed version: 19.5.3 (Community Edition)", text) + self.assertIn(" \\_ critical vulnerability: no", text) + self.assertNotIn("newer maintained releases", text) + + def test_patch_release_available(self): + state, text = plugin.evaluate(self.installed("19.4.4"), self.upstream( + severity="warning", minor="19.4.5", newer=["19.5.3", "19.4.5"])) + self.assertEqual(state, plugin.WARNING) + self.assertEqual(text.splitlines()[0], + "GitLab 19.4.4 is outdated, update available: " + "19.4.5 (latest patch release of 19.4), 19.5.3 (latest release)") + self.assertIn(" \\_ newer maintained releases: 19.5.3, 19.4.5", text) + + def test_only_newer_minor_available(self): + state, text = plugin.evaluate(self.installed("19.4.5"), self.upstream( + severity="warning", minor="19.4.5", newer=["19.5.3"])) + self.assertEqual(state, plugin.WARNING) + self.assertEqual(text.splitlines()[0], + "GitLab 19.4.5 is outdated, update available: 19.5.3 (latest release)") + + def test_patch_of_latest_minor_available(self): + state, text = plugin.evaluate(self.installed("19.5.2"), self.upstream( + severity="warning", minor="19.5.3", newer=["19.5.3"])) + self.assertEqual(state, plugin.WARNING) + self.assertEqual(text.splitlines()[0], + "GitLab 19.5.2 is outdated, update available: 19.5.3 (latest patch release of 19.5)") + + def test_security_update(self): + state, text = plugin.evaluate(self.installed("19.3.1"), self.upstream( + severity="danger", minor="19.3.7", newer=["19.5.3", "19.4.5", "19.3.7"])) + self.assertEqual(state, plugin.CRITICAL) + self.assertEqual(text.splitlines()[0], + "GitLab 19.3.1 is vulnerable, security update required: " + "19.3.7 (latest patch release of 19.3), 19.5.3 (latest release)") + + def test_critical_vulnerability(self): + state, text = plugin.evaluate(self.installed("18.11.3-ee", enterprise=True), self.upstream( + severity="danger", minor="18.11.14", newer=["19.5.3", "19.4.5", "19.3.7"], critical=True)) + self.assertEqual(state, plugin.CRITICAL) + self.assertTrue(text.startswith("GitLab 18.11.3-ee is affected by a critical vulnerability, update now: " + "18.11.14 (latest patch release of 18.11)")) + self.assertIn(" \\_ installed version: 18.11.3-ee (Enterprise Edition)", text) + self.assertIn(" \\_ critical vulnerability: yes", text) + + def test_critical_vulnerability_overrides_severity(self): + state, _ = plugin.evaluate(self.installed(), self.upstream(severity="warning", critical=True)) + self.assertEqual(state, plugin.CRITICAL) + + def test_newer_than_latest_release(self): + state, text = plugin.evaluate(self.installed("19.6.0-pre"), self.upstream(minor=None)) + self.assertEqual(state, plugin.OK) + self.assertEqual(text.splitlines()[0], "GitLab 19.6.0-pre is up to date (latest release: 19.5.3)") + self.assertNotIn("latest patch release", text) + + def test_unknown_edition(self): + _, text = plugin.evaluate(self.installed(enterprise=None), self.upstream()) + self.assertIn(" \\_ installed version: 19.5.3\n", text) class MainTesting(unittest.TestCase): - @mock.patch('requests.get') - def test_main_gitlab_400(self, mock_get): - - def side_effect(url, headers={}, timeout=30): - values = { - 'https://gitlab.com/api/v4/projects/13083/repository/tags': - MockRequest(400, ''), - } - return values[url] - - mock_get.side_effect = side_effect - - args = commandline(['-H', 'localhost', '--token', 'foobar']) - actual = main(args) - - self.assertEqual(actual, 3) - - @mock.patch('requests.get') - def test_main_premise_400(self, mock_get): - - def side_effect(url, headers={}, timeout=30): - values = { - 'https://gitlab.com/api/v4/projects/13083/repository/tags': - MockRequest(200, '[{"name": "v16.2.0", "commit": {"id": "dfa9a102b1b08b9a102b1b08b"}}, {"name": "v16.1.0", "commit": {"id": "9a102b1b08b9a102b1b08b"}}]'), - 'https://localhost/api/v4/version': - MockRequest(400, '') - } - return values[url] - - mock_get.side_effect = side_effect - - args = commandline(['-H', 'localhost', '--token', 'foobar']) - actual = main(args) - - self.assertEqual(actual, 3) - - @mock.patch('requests.get') - def test_main_ok(self, mock_get): - - def side_effect(url, headers={}, timeout=30): - values = { - 'https://gitlab.com/api/v4/projects/13083/repository/tags': - MockRequest(200, '[{"name": "v16.2.1", "commit": {"id": "9a102b1b08b9a102b1b08b"}}, {"name": "v666.2.0-rc", "commit": {"id": "foobar9a102b1b08b"}}, {"name": "v15.3.0", "commit": {"id": "bar9a102b1b08b"}}]'), - 'https://localhost/api/v4/version': - MockRequest(200, '{"version":"v16.2.1","revision":"9a102b1b08b"}') - } - return values[url] - - mock_get.side_effect = side_effect - - args = commandline(['-H', 'localhost', '--token', 'foobar']) - actual = main(args) - - self.assertEqual(actual, 0) - - @mock.patch('requests.get') - def test_main_not_ok(self, mock_get): - - def side_effect(url, headers={}, timeout=30): - values = { - 'https://gitlab.com/api/v4/projects/13083/repository/tags': - MockRequest(200, '[{"name": "v16.2.1", "commit": {"id": "dfa9a102b1b08b9a102b1b08b"}}, {"name": "v16.1.0", "commit": {"id": "9a102b1b08b9a102b1b08b"}}]'), - 'https://localhost/api/v4/version': - MockRequest(200, '{"version":"16.1.3","revision":"9a102b1b08b"}') - } - return values[url] - - mock_get.side_effect = side_effect - - args = commandline(['-H', 'localhost', '--token', 'foobar']) - actual = main(args) - - self.assertEqual(actual, 2) - - @mock.patch('requests.get') - def test_main_keyerror(self, mock_get): - - def side_effect(url, headers={}, timeout=30): - values = { - 'https://gitlab.com/api/v4/projects/13083/repository/tags': - MockRequest(200, '[{"name": "v16.2.1", "commit": {"id": "foobar"}}]'), - 'https://localhost/api/v4/version': - MockRequest(200, '{"version":"16.2.2","revision":"9a102b1b08b"}') - } - return values[url] - - mock_get.side_effect = side_effect - - args = commandline(['-H', 'localhost', '--token', 'foobar']) - actual = main(args) - - self.assertEqual(actual, 3) + def test_ok(self): + with FakeServer() as server: + server.metadata("19.5.3") + server.upstream() + state, out = run_main(server) + + self.assertEqual(state, 0) + self.assertTrue(out.startswith("[OK] GitLab 19.5.3 is up to date (latest release: 19.5.3)\n")) + self.assertEqual(server.requests[0][2].get("PRIVATE-TOKEN"), TOKEN) + + def test_warning(self): + with FakeServer() as server: + server.metadata("19.4.4") + server.upstream(severity="warning", minor="19.4.5", newer=["19.5.3", "19.4.5"]) + state, out = run_main(server) + + self.assertEqual(state, 1) + self.assertTrue(out.startswith("[WARNING] GitLab 19.4.4 is outdated")) + + def test_critical(self): + with FakeServer() as server: + server.metadata("19.3.1") + server.upstream(severity="danger", minor="19.3.7", newer=["19.5.3", "19.4.5", "19.3.7"], critical=True) + state, out = run_main(server) + + self.assertEqual(state, 2) + self.assertTrue(out.startswith("[CRITICAL] GitLab 19.3.1 is affected by a critical vulnerability")) + + def test_sends_release_without_suffix_and_no_token_upstream(self): + with FakeServer() as server: + server.metadata("19.5.3-ee", enterprise=True) + server.upstream() + run_main(server) + + self.assertEqual(server.sent_version(), {"version": "19.5.3"}) + upstream_headers = next(h for p, _, h in server.requests if p == "/check.json") + self.assertNotIn("PRIVATE-TOKEN", upstream_headers) + self.assertTrue(upstream_headers["User-Agent"].startswith("check_gitlab_version/")) + + def test_critical_vulnerability_as_string(self): + with FakeServer() as server: + server.metadata() + body = upstream_response() + body["critical_vulnerability"] = "true" + server.route("/check.json", body=body) + state, _ = run_main(server) + + self.assertEqual(state, 2) + + def test_version_check_url_with_query(self): + with FakeServer() as server: + server.metadata() + server.upstream() + args = plugin.commandline(["-H", server.url, "-t", TOKEN, "--version-check-url", + f"{server.url}/check.json?source=test"]) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(plugin.main(args), 0) + query = urllib.parse.parse_qs(server.requests[1][1]) + + self.assertEqual(query["source"], ["test"]) + self.assertIn("gitlab_info", query) + + def test_base_url_with_path(self): + with FakeServer() as server: + server.route("/gitlab/api/v4/metadata", body={"version": "19.5.3"}) + server.upstream() + args = plugin.commandline(["-H", f"{server.url}/gitlab/", "-t", TOKEN, + "--version-check-url", f"{server.url}/check.json"]) + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(plugin.main(args), 0) + + +class ErrorTesting(unittest.TestCase): + + def assert_unknown(self, server, expected): + state, out = run_main(server) + self.assertEqual(state, 3) + self.assertTrue(out.startswith("[UNKNOWN] "), out) + self.assertIn(expected, out) + self.assertNotIn(TOKEN, out) + return out + + def test_unauthorized(self): + with FakeServer() as server: + server.route("/api/v4/metadata", 401, {"message": "401 Unauthorized"}) + self.assert_unknown(server, "/api/v4/metadata returned HTTP 401: 401 Unauthorized") + + def test_insufficient_scope(self): + with FakeServer() as server: + server.route("/api/v4/metadata", 403, { + "error": "insufficient_scope", + "error_description": "The request requires higher privileges than provided by the access token.", + }) + self.assert_unknown(server, "HTTP 403: The request requires higher privileges") + + def test_server_error_with_html_body(self): + with FakeServer() as server: + server.route("/api/v4/metadata", 502, b"" + b"x" * 5000 + b"", {"Content-Type": "text/html"}) + out = self.assert_unknown(server, "returned HTTP 502") + self.assertNotIn("", out) + + def test_error_body_without_message(self): + for body in ([{"message": "x"}], {"status": "failed"}, {"message": ""}): + with self.subTest(body=body), FakeServer() as server: + server.route("/api/v4/metadata", 500, body) + out = self.assert_unknown(server, "/api/v4/metadata returned HTTP 500\n") + self.assertNotIn("failed", out) + + def test_long_error_message_is_truncated(self): + with FakeServer() as server: + server.route("/api/v4/metadata", 500, {"message": "m" * 1000}) + out = self.assert_unknown(server, "returned HTTP 500: ") + self.assertLess(len(out), 400) + + def test_redirect_is_not_followed(self): + with FakeServer() as server: + server.route("/api/v4/metadata", 302, b"", {"Location": f"{server.url}/elsewhere"}) + server.route("/elsewhere", body={"version": "19.5.3"}) + self.assert_unknown(server, f"redirects to {server.url}/elsewhere (HTTP 302)") + self.assertNotIn("/elsewhere", [p for p, _, _ in server.requests]) + + def test_invalid_json(self): + with FakeServer() as server: + server.route("/api/v4/metadata", 200, b"login", {"Content-Type": "text/html"}) + self.assert_unknown(server, "/api/v4/metadata is not valid JSON") + + def test_missing_version(self): + for body in ({"revision": "abc"}, {"version": None}, [], "19.5.3"): + with self.subTest(body=body), FakeServer() as server: + server.route("/api/v4/metadata", body=json.dumps(body).encode()) + self.assert_unknown(server, "no version in the response") + + def test_unparsable_version(self): + with FakeServer() as server: + server.route("/api/v4/metadata", body={"version": "nightly"}) + self.assert_unknown(server, "cannot parse GitLab version 'nightly'") + + def test_oversized_response(self): + with FakeServer() as server: + server.route("/api/v4/metadata", body=b'"' + b"a" * (plugin.MAX_RESPONSE_BYTES + 10) + b'"') + self.assert_unknown(server, "exceeds") + + def test_timeout(self): + with FakeServer() as server: + server.route("/api/v4/metadata", body={"version": "19.5.3"}, delay=3) + started = time.monotonic() + self.assert_unknown(server, "timed out") + self.assertLess(time.monotonic() - started, 3) + + def test_connection_refused(self): + with FakeServer() as server: + url = server.url + args = plugin.commandline(["-H", url, "-t", TOKEN, "--timeout", "2"]) + out = io.StringIO() + with contextlib.redirect_stdout(out): + state = plugin.main(args) + self.assertEqual(state, 3) + self.assertIn(f"cannot connect to {url}/api/v4/metadata", out.getvalue()) + + def test_upstream_unavailable(self): + with FakeServer() as server: + server.metadata() + server.route("/check.json", 503, b"Service Unavailable", {"Content-Type": "text/plain"}) + self.assert_unknown(server, "/check.json returned HTTP 503") + + def test_upstream_invalid_responses(self): + cases = { + "unknown severity": {**upstream_response(), "severity": "info"}, + "unknown severity None": {k: v for k, v in upstream_response().items() if k != "severity"}, + "no latest version": {**upstream_response(), "latest_version": None}, + "invalid latest_stable_version_of_minor": {**upstream_response(), "latest_stable_version_of_minor": 19}, + "invalid latest_stable_versions": {**upstream_response(), "latest_stable_versions": "19.5.3"}, + "unexpected response": ["19.5.3"], + } + for expected, body in cases.items(): + with self.subTest(expected=expected), FakeServer() as server: + server.metadata() + server.route("/check.json", body=json.dumps(body).encode()) + self.assert_unknown(server, expected) + + +class ScriptTesting(unittest.TestCase): + """Runs the plugin as Icinga does: a separate process, exit code and stdout.""" + + def run_plugin(self, *args, env=None): + environment = {k: v for k, v in os.environ.items() if k != plugin.TOKEN_ENV} + environment.update(env or {}) + return subprocess.run([sys.executable, str(PLUGIN), *args], capture_output=True, text=True, + env=environment, timeout=30, check=False) + + def test_version(self): + result = self.run_plugin("--version") + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout.strip(), plugin.__version__) + + def test_invalid_token_from_environment_is_not_printed(self): + result = self.run_plugin("-H", "127.0.0.1:1", env={plugin.TOKEN_ENV: "glpat-se\ncret"}) + self.assertEqual(result.returncode, 3) + self.assertTrue(result.stdout.startswith("[UNKNOWN] ")) + self.assertNotIn("cret", result.stdout + result.stderr) + + def test_usage_error(self): + result = self.run_plugin("-H", "git.example.com") + self.assertEqual(result.returncode, 3) + self.assertTrue(result.stdout.startswith("[UNKNOWN] check_gitlab_version.py: error: a token is required")) + self.assertEqual(result.stderr, "") + + def test_token_from_environment(self): + with FakeServer() as server: + server.metadata("19.4.4") + server.upstream(severity="warning", minor="19.4.5", newer=["19.5.3", "19.4.5"]) + result = self.run_plugin("-H", server.url, "--version-check-url", f"{server.url}/check.json", + env={plugin.TOKEN_ENV: TOKEN}) + + self.assertEqual(result.returncode, 1) + self.assertTrue(result.stdout.startswith("[WARNING] GitLab 19.4.4 is outdated")) + self.assertNotIn(TOKEN, result.stdout) + self.assertEqual(result.stderr, "") + self.assertEqual(server.requests[0][2].get("PRIVATE-TOKEN"), TOKEN) + + +if __name__ == "__main__": + unittest.main() From ae7373babb3a06992f8009beac152606c37b9ad4 Mon Sep 17 00:00:00 2001 From: Marius Hein Date: Tue, 10 Nov 2026 08:52:02 +0100 Subject: [PATCH 2/3] ci: run Python 3.9 tests on ubuntu-24.04 actions/python-versions has no Python 3.9 build for Ubuntu 26.04 (ubuntu-latest), so the 3.9 job failed in setup-python and fail-fast cancelled the other versions. - run 3.9 on ubuntu-24.04, all other versions on ubuntu-latest - set fail-fast: false so every version reports its result Assisted-by: claude-code:claude-opus-5-5 Signed-off-by: Marius Hein --- .github/workflows/unittest.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 6066f46..a1efbc1 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -21,10 +21,16 @@ jobs: make lint test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] + os: [ubuntu-latest] + include: + # No Python 3.9 builds for newer Ubuntu releases + - python-version: "3.9" + os: ubuntu-24.04 name: Test (Python ${{ matrix.python-version }}) steps: - name: Checkout From 8f7613ca81a74d38e082ee502439d96c28c64e75 Mon Sep 17 00:00:00 2001 From: Marius Hein Date: Tue, 10 Nov 2026 09:33:03 +0100 Subject: [PATCH 3/3] docs: recommend an access token with the ai_features scope On GitLab CE, the REST API accepts ai_features only for /api/v4/metadata and /api/v4/version, so a group or project access token with this scope can read the version and nothing else. Verified on GitLab 19.5 CE: projects, groups, users, search, GraphQL and Git are denied. - README: ai_features group/project access token (role Guest) as the recommended token, including an API call to create it; note that the scope also grants GitLab Duo API access on EE - README: read_user personal access token as the alternative, warn against read_api and api (read access to all internal projects) - --token help: name both scopes Assisted-by: claude-code:claude-opus-5-5 Signed-off-by: Marius Hein --- README.md | 51 +++++++++++++++++++++++++++++++---------- check_gitlab_version.py | 2 +- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 45808f1..9f2fdbd 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,45 @@ Proxies are honored through the `https_proxy` and `no_proxy` environment variabl ## Access token The GitLab API returns the version only to authenticated users, so the plugin needs a token. -Use the token with the least privileges: +The plugin only calls `GET /api/v4/metadata`. Use a token that can do as little else as possible. -* Create a dedicated user, for example `monitoring`, as a regular user, not as an administrator, - and without any group or project memberships. -* Create a personal access token for this user with only the `read_user` scope. +### Recommended: group or project access token with the `ai_features` scope -This token can read the profile of the user and public user information, but no projects, -groups or repositories. +Create a group or project access token with the role `Guest` and only the `ai_features` scope. +In the GitLab Community Edition, the REST API accepts this scope only for `/api/v4/metadata` +and `/api/v4/version`. The token cannot read projects, groups, users or repositories, neither +through the REST API nor through GraphQL or Git, and it is not bound to a person. +Tested with GitLab 19.5 CE. -Project and group access tokens are not recommended: they cannot have the `read_user` scope and need -`read_api`, which grants read access to every project the token's bot user can see. +If the web form does not offer the scope, create the token through the API. This needs the Owner +role in the group: -Personal access tokens expire. Monitor the expiry date, or renew the token in time. +``` +curl --request POST \ + --header "PRIVATE-TOKEN: " \ + --header "Content-Type: application/json" \ + --data '{"name": "check-gitlab-version", "scopes": ["ai_features"], "access_level": 10, "expires_at": ""}' \ + "https://gitlab.example.com/api/v4/groups//access_tokens" +``` + +In the Enterprise Edition (Premium, Ultimate), the `ai_features` scope also grants access to the +GitLab Duo API. If that is not acceptable, use a personal access token with the `read_user` scope. + +### Alternative: personal access token with the `read_user` scope + +Create a regular user, not an administrator and without any group or project memberships, and a +personal access token for this user with only the `read_user` scope. The token can read user +profiles, but no projects, groups or repositories. Group and project access tokens cannot have +this scope. + +### Avoid the `read_api` and `api` scopes + +Both grant read access to every internal project of the instance, even for a bot user with the +role `Guest` in an empty project. + +### Expiry + +Access tokens expire. Monitor the expiry date, or renew the token in time. ## Usage @@ -63,8 +89,8 @@ options: -V, --version show program's version number and exit -H, --host HOST GitLab host name (uses https://) or base URL, e.g. https://example.com/gitlab - -t, --token TOKEN GitLab personal access token with the read_user scope - (default: $CHECK_GITLAB_VERSION_TOKEN) + -t, --token TOKEN GitLab access token with the ai_features or read_user + scope (default: $CHECK_GITLAB_VERSION_TOKEN) --version-check-url VERSION_CHECK_URL URL of the GitLab version check service (default: https://version.gitlab.com/check.json) @@ -152,7 +178,8 @@ The Icinga Director does not support `env` on commands. There, pass the token wi * Enterprise Edition instances work. Before, they always ended as UNKNOWN. * The output format changed and no longer contains HTML. * The environment variable `CHECK_GITLAB_VERSION_URL` is replaced by `--version-check-url`. -* The token can be passed in `CHECK_GITLAB_VERSION_TOKEN`, and `read_user` is enough as scope. +* The token can be passed in `CHECK_GITLAB_VERSION_TOKEN`, and it needs only the `ai_features` + or `read_user` scope instead of `read_api`, see [Access token](#access-token). * The `requests` module is no longer needed. ## Development diff --git a/check_gitlab_version.py b/check_gitlab_version.py index 1d93e4f..db09789 100755 --- a/check_gitlab_version.py +++ b/check_gitlab_version.py @@ -132,7 +132,7 @@ def commandline(args: list[str]) -> argparse.Namespace: parser.add_argument("-H", "--host", required=True, help="GitLab host name (uses https://) or base URL, e.g. https://example.com/gitlab") parser.add_argument("-t", "--token", default=os.environ.get(TOKEN_ENV), - help=f"GitLab personal access token with the read_user scope (default: ${TOKEN_ENV})") + help=f"GitLab access token with the ai_features or read_user scope (default: ${TOKEN_ENV})") parser.add_argument("--version-check-url", default=DEFAULT_VERSION_CHECK_URL, help="URL of the GitLab version check service (default: %(default)s)") parser.add_argument("--timeout", type=_positive_int, default=DEFAULT_TIMEOUT,