gitlab-setup.py
711 lines · 27,958 bytes
SHA-256 · 460a04e004698acb5e90ae4d52e2982427029913690cba4ffda00f1bd9bcf1d5
Check what you downloaded against the fingerprint above: shasum -a 256 gitlab-setup.py on macOS, sha256sum gitlab-setup.py on Linux.
What it does, in short
| Runs on | Python 3.6 or newer |
| Third-party packages | None. Standard library only: argparse, getpass, hashlib, json, ssl, sys, traceback, urllib |
| Writes to your GitLab | Exactly one call — POST /api/v4/hooks or PUT /api/v4/hooks/:id |
| Deletes anything | Never. The script has no delete call in it |
| Writes to disk | Nothing. No config file, no cache, no state |
| Admin token | Read from a no-echo prompt or stdin. There is deliberately no --token flag and no environment variable |
| Dry run | --dry-run shows exactly what it would change without writing |
How to run it
python3 gitlab-setup.py --gitlab-url https://gitlab.example.com --dry-run python3 gitlab-setup.py --gitlab-url https://gitlab.example.com
It asks for three things: the web trigger URL and the shared secret, both from the app's configuration screen in Jira, and a GitLab administrator token. Run it again whenever you rotate the shared secret — it finds the hook it created and updates it in place.
The source, in full
#!/usr/bin/env python3
"""Register the Jira integration's GitLab system hook. One run, no stored state.
This script is run by a GitLab administrator, inside their own network, against
their own self-managed GitLab. It registers ONE instance-wide system hook that
delivers push and merge request events to Jira. There is no per-repository
configuration, now or later.
python3 gitlab-setup.py --gitlab-url https://gitlab.example.com --dry-run
python3 gitlab-setup.py --gitlab-url https://gitlab.example.com
Run it again whenever you rotate the shared secret in Jira: it finds the hook it
created and updates it in place.
================================ SECURITY ==================================
The admin token is used for this one run and then it is gone. Concretely, and
verifiably in this file:
* It is read from a no-echo prompt, or from stdin (--token-stdin for the
token alone, --stdin for an unattended run). There is deliberately NO
--token flag and no environment variable: both are readable by other
users on this host (`ps`, /proc/<pid>/environ), and a flag also lands
in your shell history. Feed --stdin from a file or a password manager,
never from `echo`, which puts the secret in your history anyway.
* It is held in one attribute, Session._token, and read by exactly one
method, Session._call, which puts it in the PRIVATE-TOKEN header.
* Nothing is written to disk. No config file, no cache, no state. The
script has no write-to-file call in it at all.
* It is never printed. Not on the success path, not in an error, not in a
traceback -- everything printed on a failure path goes through scrub().
There is no debug or verbose flag that dumps headers or request bodies;
that flag is exactly how this project leaked a credential once before.
* Redirects are refused (_NoRedirect), so the header cannot be replayed to
a host other than the one you named.
The shared secret from the Jira config page is treated the same way, and is
only ever shown as a short fingerprint.
The web trigger URL IS printed in full, deliberately. Its path segment is a
credential, but it is also the thing you are being asked to approve, and you
cannot verify a target you cannot see. The boundary this script enforces is:
nothing on disk, nothing in the argument list, nothing in shell history,
nothing in a shared log. Your own terminal is the intended destination.
============================================================================
Exit codes: 0 success or dry run, 2 bad usage (argparse), 3 refused to act,
4 API or authentication error, 5 TLS verification failed.
Requires Python 3.6+ and nothing else. No third-party packages.
"""
import argparse
import getpass
import hashlib
import json
import ssl
import sys
import traceback
import urllib.error
import urllib.request
from urllib.parse import urlsplit, urlunsplit
EXIT_OK = 0
EXIT_REFUSED = 3
EXIT_API = 4
EXIT_TLS = 5
# The events this hook subscribes to. This set tracks what the Jira app's
# handler actually consumes today (src/index.js: object_kind "push" and
# "merge_request"); everything else it receives it discards. Subscribing to
# an event we discard is not free -- it costs one Forge invocation per
# delivery, on every tag push across every project on the instance.
#
# Every flag is sent explicitly, including the False ones. GitLab documents
# no defaults for these, and repository_update_events has historically
# defaulted to on, so an omitted flag is not the same as an off flag.
#
# tag_push_events comes back when releases ship. Change it here and in the
# app's handler together.
DESIRED = {
"push_events": True,
"merge_requests_events": True,
"tag_push_events": False,
"repository_update_events": False,
"enable_ssl_verification": True,
}
EVENT_LABELS = [
("push_events", "push events"),
("merge_requests_events", "merge request events"),
("tag_push_events", "tag push events"),
("repository_update_events", "repository update"),
]
# Shown in Admin Area > System Hooks, so a colleague who finds this hook can
# tell what it is instead of deleting it to see what breaks. Older GitLab
# versions reject these two fields; see _write_hook for the fallback.
HOOK_NAME = "Jira development information"
HOOK_DESCRIPTION = (
"Created by the Jira app's setup script. Sends push and merge request "
"events to Jira. Delete this hook to disconnect."
)
ACTION_CREATE = "create"
ACTION_UPDATE = "update"
ACTION_REFUSE = "refuse"
class Refused(Exception):
"""A precondition failed. Nothing was written."""
class ApiError(Exception):
"""GitLab said no, or could not be reached."""
class TlsError(Exception):
"""The TLS certificate could not be verified."""
# --------------------------------------------------------------------------
# Pure helpers. No network, no credentials held, unit-tested in selftest.py.
# --------------------------------------------------------------------------
def fingerprint(value):
"""Short, non-reversible fingerprint of a secret, for display only.
Same convention as the app's src/redact.js: 4 hex chars is 16 bits,
enough to tell "this is the same secret Jira shows me" apart from "a
different one", useless for an attack on a 256-bit secret.
"""
if not value:
return "(none)"
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()[:4]
def scrub(text, secrets):
"""Replaces any secret that appears in text with its fingerprint.
Applied to everything printed on a failure path. GitLab's own error
bodies do not contain our credentials, and a Python traceback does not
print local variables -- this is the belt to that pair of braces, so
that "the token is never printed" holds even for a failure nobody
anticipated.
"""
out = text
for secret in secrets:
if secret:
out = out.replace(secret, "[REDACTED %s]" % fingerprint(secret))
return out
def normalize_hook_url(url):
"""Canonical form used to decide whether a hook is ours.
Scheme and host are case-insensitive per RFC 3986; the path is not, and
a Forge web trigger path segment is case-sensitive, so it is compared
byte for byte. One trailing slash is ignored because the GitLab UI adds
one on occasion. The fragment is dropped -- it is never sent.
"""
parts = urlsplit(url.strip())
path = parts.path
if len(path) > 1 and path.endswith("/"):
path = path[:-1]
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path,
parts.query, ""))
def host_of(url):
return urlsplit(url).netloc.lower()
def decide(hooks, webtrigger_url):
"""Works out what to do, from the hooks the instance already has.
Matching is on the web trigger URL and nothing else: GitLab never
returns a hook's token (only token_present), so comparing secrets is
impossible by construction.
"""
target = normalize_hook_url(webtrigger_url)
target_host = host_of(target)
matches = []
same_host_others = []
for hook in hooks:
raw = hook.get("url") or ""
normalized = normalize_hook_url(raw)
if normalized == target:
matches.append(hook)
elif host_of(normalized) == target_host:
# A Forge web trigger URL's host identifies the app and
# environment, so a different path on the same host is very
# likely this same app on an older, rotated URL. "Very likely"
# is enough to warn about and nowhere near enough to act on:
# this only ever produces a line of output.
same_host_others.append(hook)
plan = {
"webtrigger_url": webtrigger_url.strip(),
"desired": dict(DESIRED),
"same_host_others": same_host_others,
"duplicates": [],
"hook_id": None,
"current": None,
}
if len(matches) == 0:
plan["action"] = ACTION_CREATE
elif len(matches) == 1:
plan["action"] = ACTION_UPDATE
plan["hook_id"] = matches[0].get("id")
plan["current"] = matches[0]
else:
# Never guess which duplicate to keep, and never delete: this script
# holds instance-wide admin rights for one run, and the extra hook
# may not even be ours. Refusing costs the admin one click; guessing
# wrong costs them either double deliveries or a deleted hook.
plan["action"] = ACTION_REFUSE
plan["duplicates"] = [h.get("id") for h in matches]
return plan
def _flag(value):
if value is True:
return "on"
if value is False:
return "off"
return "?"
def render_plan(plan, secret):
"""The dry-run output, and the same block printed before a real write."""
lines = []
action = plan["action"]
if action == ACTION_REFUSE:
ids = ", ".join("#%s" % i for i in plan["duplicates"])
lines.append("Refusing to continue: %d system hooks already point at "
"this URL (%s)." % (len(plan["duplicates"]), ids))
lines.append("Remove the duplicate in Admin Area > System Hooks, then "
"re-run.")
lines.append("No changes were made.")
return "\n".join(lines)
if action == ACTION_UPDATE:
lines.append("Would UPDATE system hook #%s - matched on hook URL"
% plan["hook_id"])
else:
lines.append("Would CREATE a new system hook")
lines.append("")
lines.append(" Hook URL %s" % plan["webtrigger_url"])
lines.append(" Secret token %s (re-sent on every run)"
% fingerprint(secret))
current = plan["current"] or {}
first = True
for key, label in EVENT_LABELS:
prefix = " Events " if first else " "
first = False
if action == ACTION_UPDATE:
lines.append("%s %-22s %-4s -> %s"
% (prefix, label, _flag(current.get(key)),
_flag(plan["desired"][key])))
else:
lines.append("%s %-22s %s"
% (prefix, label, _flag(plan["desired"][key])))
if action == ACTION_UPDATE:
lines.append(" SSL verify %-22s %-4s -> %s"
% ("", _flag(current.get("enable_ssl_verification")),
_flag(plan["desired"]["enable_ssl_verification"])))
else:
lines.append(" SSL verify %-22s %s"
% ("", _flag(plan["desired"]["enable_ssl_verification"])))
for hook in plan["same_host_others"]:
lines.append("")
lines.append(" Note: system hook #%s points at the same host with a "
"different path:" % hook.get("id"))
lines.append(" %s" % hook.get("url"))
lines.append(" That looks like this app on an older URL. It "
"will keep delivering.")
lines.append(" This script will not touch it - remove it "
"yourself if it is stale.")
return "\n".join(lines)
def check_gitlab_url(url):
"""Rejects a target we would not send an admin token to."""
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise Refused("--gitlab-url must be a full URL, for example "
"https://gitlab.example.com")
host = parts.hostname or ""
if parts.scheme == "http" and host not in ("localhost", "127.0.0.1", "::1"):
raise Refused(
"Refusing to send an administrator token over plain http to %s.\n"
"Use the https URL of your instance." % host)
return url.rstrip("/")
def check_webtrigger_url(url):
parts = urlsplit(url)
if parts.scheme != "https" or not parts.netloc or len(parts.path) < 2:
raise Refused("That does not look like a web trigger URL. Copy it "
"from the app's configuration screen in Jira; it "
"starts with https:// and ends in a long path segment.")
return url.strip()
# --------------------------------------------------------------------------
# Transport. The only code that touches the network.
# --------------------------------------------------------------------------
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Refuses every redirect.
urllib re-sends request headers to the redirect target, so following a
redirect would hand the PRIVATE-TOKEN header to whatever host the
response names. An admin token is not something to replay at a location
chosen by someone else, so a redirect is an error with an instruction
rather than something handled transparently.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ApiError(
"%s redirected to %s.\nUse that URL directly with --gitlab-url; "
"this script does not follow redirects, because that would send "
"your token to a host you did not name." % (req.full_url, newurl))
def make_transport(ca_bundle=None):
"""Builds the callable Session uses: (method, url, headers, body) -> tuple.
Replaced wholesale in selftest.py, which is why the credential handling
can be tested without a GitLab.
"""
context = ssl.create_default_context(cafile=ca_bundle)
opener = urllib.request.build_opener(
_NoRedirect(), urllib.request.HTTPSHandler(context=context))
def transport(method, url, headers, body):
request = urllib.request.Request(url, data=body, headers=headers,
method=method)
try:
response = opener.open(request, timeout=30)
except urllib.error.HTTPError as error:
# A 4xx is an answer, not a transport failure: read the body so
# GitLab's own message can be shown.
return error.code, error.read()
except urllib.error.URLError as error:
reason = error.reason
if isinstance(reason, ssl.SSLError):
raise TlsError(
"Could not verify the TLS certificate of %s (%s).\n"
"If your instance uses an internal certificate authority, "
"pass its certificate with --ca-bundle /path/to/ca.pem."
% (host_of(url), reason))
raise ApiError("Could not reach %s: %s" % (host_of(url), reason))
with response:
return response.getcode(), response.read()
return transport
class Session:
"""Authenticated GitLab API calls. Holds the admin token; nothing else does.
read_only=True makes any non-GET raise. That is what --dry-run runs on,
so "a dry run performs no writes" is enforced here rather than merely
being true of the current control flow.
"""
def __init__(self, base_url, token, transport, read_only=False):
self._base = base_url.rstrip("/")
self._token = token
self._transport = transport
self._read_only = read_only
def __repr__(self):
# Explicit, so that no accident of formatting can put the token in a
# string. The default repr would not have, but this is cheap.
return "<Session %s read_only=%s>" % (self._base, self._read_only)
def _call(self, method, path, payload=None):
if self._read_only and method != "GET":
raise RuntimeError(
"read-only session refused a %s request" % method)
url = self._base + "/api/v4" + path
headers = {
"PRIVATE-TOKEN": self._token, # the token's only destination
"Accept": "application/json",
"User-Agent": "jira-gitlab-setup",
}
body = None
if payload is not None:
body = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
status, raw = self._transport(method, url, headers, body)
try:
parsed = json.loads(raw.decode("utf-8")) if raw else None
except ValueError:
parsed = None
if status >= 400:
raise ApiError(_api_message(status, parsed, path))
return parsed
def get(self, path):
return self._call("GET", path)
def get_all(self, path):
"""Reads every page.
Paging matters here: the default page size is 20, and a hook that
exists on page 2 but is not looked for would be silently created a
second time. Double delivery of every event is exactly the failure
this script must not produce.
"""
results = []
page = 1
while page <= 50:
separator = "&" if "?" in path else "?"
batch = self._call(
"GET", "%s%sper_page=100&page=%d" % (path, separator, page))
if not batch:
break
results.extend(batch)
if len(batch) < 100:
break
page += 1
return results
def post(self, path, payload):
return self._call("POST", path, payload)
def put(self, path, payload):
return self._call("PUT", path, payload)
def _api_message(status, parsed, path):
detail = ""
if isinstance(parsed, dict):
detail = parsed.get("message") or parsed.get("error") or ""
if not isinstance(detail, str):
detail = json.dumps(detail)
if status == 401:
return ("GitLab rejected the token (401). It is invalid, expired, or "
"lacks the 'api' scope.")
if status == 403:
return ("GitLab refused access (403) on %s. System hooks require an "
"administrator token; this one is not an administrator's."
% path)
if status == 404 and path.startswith("/hooks/"):
# Two causes, one remedy. Editing a system hook through the API
# arrived in a later GitLab version than creating one, and
# self-managed instances lag; the hook may also simply have been
# deleted since the listing a moment ago. Recreating it fixes both,
# and deleting-to-work-around is left to the admin rather than done
# here on a guess.
return ("PUT %s returned 404: either your GitLab is too old to edit "
"a system hook through the API, or that hook no longer "
"exists.\nDelete it in Admin Area > System Hooks if it is "
"still there, then run this script again to recreate it."
% path)
return "GitLab returned %d on %s%s" % (
status, path, (": " + detail) if detail else "")
# --------------------------------------------------------------------------
# Input. Kept at module level so selftest.py can replace them.
# --------------------------------------------------------------------------
def prompt_visible(text):
"""Reads a visible line, from the terminal even when stdin is a pipe."""
if sys.stdin.isatty():
return input(text).strip()
try:
tty = open("/dev/tty", "r+")
except (IOError, OSError):
raise Refused("No terminal available to ask for the web trigger URL. "
"Run this script interactively.")
try:
tty.write(text)
tty.flush()
return tty.readline().strip()
finally:
tty.close()
def prompt_secret(text):
"""No-echo read. getpass uses /dev/tty, so a piped stdin is fine."""
return getpass.getpass(text).strip()
def read_token_from_stdin():
line = sys.stdin.readline()
if not line.strip():
raise Refused("--token-stdin was given but stdin was empty.")
return line.strip()
# The three values, in the order --stdin expects them.
STDIN_FIELDS = ("web trigger URL", "shared secret", "administrator token")
def read_all_from_stdin():
"""Reads the three inputs from stdin, one per line, for an unattended run.
Why this exists as well as the prompts: with prompts only, the script
cannot run where there is no terminal -- CI, configuration management,
or a demonstration -- and something that cannot be exercised
unattended does not get exercised.
It keeps the same property the prompts have and the reason there is no
--token flag: the values arrive on a file descriptor, not in the
argument list, so they stay out of `ps` and out of shell history.
Feed it from a file or a password manager, never from `echo`, which
puts the secret in your history itself.
"""
values = []
for index, field in enumerate(STDIN_FIELDS):
line = sys.stdin.readline()
if not line.strip():
raise Refused(
"--stdin expects %d lines: %s.\nLine %d (%s) was empty or "
"missing." % (len(STDIN_FIELDS), ", ".join(STDIN_FIELDS),
index + 1, field))
values.append(line.strip())
return values
# --------------------------------------------------------------------------
# The run itself.
# --------------------------------------------------------------------------
def parse_args(argv):
parser = argparse.ArgumentParser(
description="Register the Jira integration's GitLab system hook.",
epilog="The administrator token is used for this run only and is "
"never stored, printed or written to disk.")
parser.add_argument("--gitlab-url", required=True,
help="Base URL of your GitLab, e.g. "
"https://gitlab.example.com")
parser.add_argument("--dry-run", action="store_true",
help="Show exactly what would change and make no "
"write calls at all.")
parser.add_argument("--token-stdin", action="store_true",
help="Read the administrator token from stdin instead "
"of prompting. The other two are still asked "
"for at the terminal.")
parser.add_argument("--stdin", action="store_true",
help="Unattended: read all three inputs from stdin, "
"one per line, in the order %s."
% ", ".join(STDIN_FIELDS))
parser.add_argument("--ca-bundle", metavar="PATH",
help="Certificate authority bundle, for an instance "
"with an internal certificate.")
args = parser.parse_args(argv)
if args.stdin and args.token_stdin:
parser.error("--stdin already reads the token; drop --token-stdin.")
return args
def _write_hook(session, plan, secret):
"""Creates or updates, with a fallback for older GitLab versions.
name and description arrived in later GitLab versions. They are worth
sending -- an unlabelled hook in the admin UI is one a colleague deletes
to find out what it was -- but not worth failing over, so a 400 is
retried once without them.
"""
payload = dict(plan["desired"])
payload["url"] = plan["webtrigger_url"]
payload["token"] = secret
labelled = dict(payload)
labelled["name"] = HOOK_NAME
labelled["description"] = HOOK_DESCRIPTION
if plan["action"] == ACTION_CREATE:
send = lambda body: session.post("/hooks", body)
else:
send = lambda body: session.put("/hooks/%s" % plan["hook_id"], body)
try:
return send(labelled)
except ApiError as error:
if "returned 400" not in str(error):
raise
return send(payload)
def _run(args, creds):
gitlab_url = check_gitlab_url(args.gitlab_url)
if args.stdin:
webtrigger_raw, secret, token = read_all_from_stdin()
creds["secret"] = secret
creds["token"] = token
else:
print("Paste these two from the app's configuration screen in Jira.")
webtrigger_raw = prompt_visible(" Web trigger URL: ")
secret = prompt_secret(" Shared secret (not shown): ")
creds["secret"] = secret
if not secret:
raise Refused("The shared secret is required. It is on the same "
"configuration screen as the web trigger URL.")
if args.token_stdin:
token = read_token_from_stdin()
else:
token = prompt_secret(" GitLab administrator token (not shown): ")
creds["token"] = token
if not token:
raise Refused("An administrator token is required. System hooks "
"cannot be registered without one.")
webtrigger_url = check_webtrigger_url(webtrigger_raw)
session = Session(gitlab_url, token, make_transport(args.ca_bundle),
read_only=args.dry_run)
version = session.get("/version") or {}
user = session.get("/user") or {}
# is_admin is only present for administrators on most versions, so its
# absence is reported as unknown rather than as "no" -- the GET /hooks
# below is the real test, and its 403 says so plainly.
if user.get("is_admin") is True:
admin = "yes"
elif user.get("is_admin") is False:
admin = "no"
else:
admin = "unknown"
print("")
print("Target instance : %s (GitLab %s)"
% (gitlab_url, version.get("version", "unknown")))
print("Authenticated as: %s (administrator: %s)"
% (user.get("username", "unknown"), admin))
print("")
hooks = session.get_all("/hooks")
plan = decide(hooks, webtrigger_url)
print(render_plan(plan, secret))
print("")
if plan["action"] == ACTION_REFUSE:
return EXIT_REFUSED
if args.dry_run:
print("DRY RUN - no changes were made. Re-run without --dry-run to "
"apply.")
return EXIT_OK
result = _write_hook(session, plan, secret) or {}
hook_id = result.get("id", plan["hook_id"])
verb = "created" if plan["action"] == ACTION_CREATE else "updated"
print("Applied. System hook #%s %s. Push and merge request events from "
"every project on this instance now go to Jira." % (hook_id, verb))
print("")
print("The administrator token was used for this run only. It was not "
"written to disk, never appeared in the command line, and nothing "
"of it remains once this process exits.")
print("If you created it for this setup, you can revoke it now:")
print(" %s/-/user_settings/personal_access_tokens" % gitlab_url)
return EXIT_OK
def main(argv=None):
args = parse_args(argv)
# Holds the two secrets as soon as they are read, so that the failure
# handlers below can scrub them out of anything they print. Before they
# are read there is nothing to scrub; after the process exits there is
# nothing left at all.
creds = {"token": None, "secret": None}
try:
return _run(args, creds)
except Refused as error:
print(str(error), file=sys.stderr)
print("No changes were made.", file=sys.stderr)
return EXIT_REFUSED
except TlsError as error:
print(scrub(str(error), [creds["token"], creds["secret"]]),
file=sys.stderr)
print("No changes were made.", file=sys.stderr)
return EXIT_TLS
except ApiError as error:
print(scrub(str(error), [creds["token"], creds["secret"]]),
file=sys.stderr)
print("No changes were made.", file=sys.stderr)
return EXIT_API
except KeyboardInterrupt:
print("\nInterrupted. No changes were made.", file=sys.stderr)
return EXIT_REFUSED
except Exception: # noqa: BLE001 - deliberate, see scrub()
print(scrub(traceback.format_exc(),
[creds["token"], creds["secret"]]), file=sys.stderr)
print("Unexpected error. No changes were made.", file=sys.stderr)
return EXIT_API
if __name__ == "__main__":
sys.exit(main())