Diagnostic GitHub API
the webhook posts your payloads to an http:// URL
The hook works. It has a secret, it is subscribed to the right events, the delivery log is a column of 200s, and insecure_ssl reads 0, which is the field the last security review looked at. The URL is http://hooks.internal.acme.io/github, so every payload and the signature that authenticates it have been crossing the network as plain text since the day it was created.
Read GET /repos/{owner}/{repo}/hooks and GET /orgs/{org}/hooks, take config.url, and look at the scheme. Anything that is not https is the finding. GitHub will deliver to http:// without complaint, and nothing in the UI, the delivery log or the API warns about it.
The detail that keeps this hidden is that insecure_ssl stays "0" on a plaintext hook, because there is no certificate to verify when there is no TLS. So the field a reviewer checks says the reassuring thing while the transport is completely open. A signature does not help either: it proves the payload came from GitHub, it does not conceal a single byte of it, and it travels in a header of the same cleartext request.
The problem in plain words
Almost every one of these starts as a hostname that was internal when it was written. Somebody points a hook at a box inside the network during a migration, on a plain port because there is no certificate for an internal name and getting one is a week of someone else's time. Then the box moves behind a public load balancer, or the DNS name becomes externally resolvable, or the whole thing gets lifted into a cloud account, and the URL survives every one of those moves untouched, because it never stopped working.
What makes it stay hidden is that all the usual instruments read green. The delivery log is full of successes, since plaintext delivery succeeds perfectly well. The secret is set, so the payloads are signed and the receiver's verification passes. insecure_ssl is "0", which is the value everyone is looking for, and it is "0" precisely because GitHub is not verifying a certificate it was never offered. Three separate healthy-looking signals, one of them actively misleading.
The other family is the reverse: an http:// URL pointing at localhost, a 192.168. address or a tunnel hostname left over from development. That one is not a confidentiality problem, because GitHub cannot reach it at all — it is a hook that has been failing or timing out since it was created, sitting in a list of hooks that somebody is going to count as working. The script separates the two, because one needs a certificate and the other needs deleting.
Why it happens
What crosses the wire is the repository, not just an event name. Webhook payloads are large and specific: commit messages, branch names, file paths, the full body of an issue or a pull request comment, the login of everyone involved. On a private repository that is the private repository, arriving in readable JSON at every device on the path. The signature is in a header of the same request and is equally readable.
The signature proves origin and provides nothing else. This is the confusion worth naming, because it is the reason people are comfortable. HMAC gives you integrity and authenticity: nobody can alter the payload or forge one without the secret. It gives you no confidentiality at all. An observer reads the whole payload and, separately, can replay the entire request verbatim at your receiver, signature included, as many times as they like.
insecure_ssl is not a safety net. It governs certificate verification during a TLS handshake, and an http:// hook never performs one, so the field sits at its default and means nothing. A hook can therefore pass an audit that checks the flag while being the least protected hook in the organization. That is why this note reads the scheme and the neighbouring note reads the flag, and why each hands the other its own cases rather than counting them.
An unreachable URL is a different finding with a similar shape. http://localhost:3000/hooks is not leaking anything, because GitHub's delivery infrastructure cannot route to your laptop. It is a dead hook, and it will show as connection errors or timeouts in the delivery log. Reporting it as a confidentiality problem wastes the reader's afternoon; reporting it as a hook that has never worked is useful.
The repair includes a rotation, and the URL is not the only thing to send. The secret has been signing payloads on an open channel, so change it. And because a webhook's config is replaced rather than merged, the update carries the new URL, the content type and the new secret together — a partial write here is the standard way to end up with a hook whose secret is the literal mask it was read back as.
The fix, as a flow
The scheme is the whole read, and everything else in the script exists to stop two different problems being reported as one. A plaintext hook on a routable host is a readable feed of your repository; the same scheme on a loopback or a private range is a hook GitHub has never been able to reach, which is a dead integration rather than a leak. One small function names the trap that keeps this alive through real audits: on a hook with no TLS, insecure_ssl reads the reassuring zero, because there is no certificate to verify.
How to fix it
Collect the URLs from every scope
Repository hooks, organization hooks and a GitHub App's own hook are three independent places a URL can live. Read all of them, because the plaintext one is almost never the hook anybody remembers creating: it is the one added during a migration by somebody who has since changed teams.
Parse the scheme, and normalise before you judge
Lower-case it and take what precedes ://. A URL with no scheme at all, or one with something unexpected, is its own answer rather than being folded into either the safe or the unsafe pile. Strip any query string and any user:pass@ before printing, because this script prints its findings and those are the two places a credential hides in a URL.
Split leaking from unreachable
Check whether the host is a loopback address, a private range, a link-local address or an obviously local name. A plaintext hook on a routable host is exposing payloads; a plaintext hook on localhost is a hook that has never delivered anything. Same field, different finding, different repair.
Notice when the compliant-looking field is the misleading one
A hook with an http:// URL and insecure_ssl at "0" reads as compliant on the field most checklists sample. The script calls that combination out by name, because it is the specific reason this problem survives audits that were genuinely performed.
Move it behind HTTPS, then rotate
Put the receiver behind a certificate that chains to a public root, update the hook with the full config — new URL, content type, new secret — and confirm the next delivery succeeds. Rotate rather than reuse: that secret has been travelling in cleartext next to the payloads it signs.
How to check it worked
The re-read shows an https scheme on every hook, and the delivery log continues without a gap.
GITHUB_TOKEN=$GH_READONLY python3 github_hook_transport.py --repo acme-corp/api --org acme-corp
# 3 hook(s) on acme-corp/api, 2 hook(s) on acme-corp
# plaintext: hook 512334455 posts to http://hooks.internal.acme.io/github over
# an unencrypted connection. insecure_ssl reads "0", which is what a hook with
# no TLS at all always reads.
# repair: move the receiver behind HTTPS, then send the hook's full config with
# the new URL, the content type and a new secret. Rotate: that secret has been
# signing payloads on an open channel.
# plaintext-unreachable: hook 512334999 posts to http://localhost:3000/hooks,
# which GitHub cannot route to. This hook has never delivered anything.
# after the change
# encrypted: hook 512334455 posts to https://hooks.acme.io/github
The full code
One GET per scope and one field, and the interesting part is everything the script refuses to conflate. A plaintext hook on a routable host is leaking payloads; the same scheme on a loopback or private address is a hook GitHub has never been able to reach, which is a different sentence and a different repair. An https hook with certificate verification disabled belongs to the neighbouring note and is handed over rather than counted here. And one small pure function exists only to name the trap: a hook whose insecure_ssl reads 0 while its URL has no TLS at all, which is how this survives an audit that was actually carried out.
"""Find webhooks that deliver over plaintext HTTP, and say which ones leak.
Read only. Every call is a GET. Changing a hook's URL is a write and this
script does not do it: it prints the change, as a full config rather than a
single field, because a webhook's config is replaced rather than merged and the
secret you read back is a mask.
GitHub will deliver to an http:// URL without complaint. The payload and the
signature header that authenticates it both cross the network as plain text. A
signature proves the payload came from GitHub; it conceals nothing and it can
be replayed by anyone who saw it.
Two things that look the same in this field are kept apart:
http:// on a routable host payloads are readable in transit
http:// on a private address GitHub cannot route there at all, so this
hook has never delivered anything
And one thing that hides here is named: insecure_ssl reads "0" on a plaintext
hook, because there is no certificate to verify when there is no TLS, so the
field an audit samples reports the reassuring value.
The secret is never printed, and neither is any query string or userinfo in a
hook URL.
Environment:
GITHUB_TOKEN a read-only token that can see the repository's hooks
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_hook_transport")
API = "https://api.github.com"
UA = "github-hook-transport/1.0"
# Names that never resolve to somewhere GitHub can deliver.
LOCAL_NAMES = ("localhost", "localhost.localdomain", "ip6-localhost")
LOCAL_SUFFIXES = (".localhost", ".local", ".internal", ".lan", ".home.arpa",
".localdomain")
# States that mean payloads are readable in transit.
LEAKING = ("plaintext",)
def config_of(hook):
"""The config object of a hook, or an empty dict. Pure."""
if not isinstance(hook, dict):
return {}
config = hook.get("config")
return config if isinstance(config, dict) else {}
def raw_url(hook):
"""The configured URL, trimmed, or "". Pure."""
return str(config_of(hook).get("url") or "").strip()
def safe_url(url):
"""A URL with its query string and any userinfo removed. Pure.
This script prints URLs, and those are the two places a credential hides in
one: a token in a query parameter and a user:pass pair before the host.
"""
text = str(url or "").strip()
if not text:
return ""
text = text.split("?", 1)[0].split("#", 1)[0]
if "://" in text:
scheme, rest = text.split("://", 1)
else:
scheme, rest = "", text
if "@" in rest:
rest = rest.rsplit("@", 1)[1]
rest = "<redacted>@" + rest
return ("%s://%s" % (scheme, rest)) if scheme else rest
def scheme_of(url):
"""The lower-cased scheme of a URL, or "". Pure."""
text = str(url or "").strip()
return text.split("://", 1)[0].lower() if "://" in text else ""
def host_of(url):
"""The lower-cased host of a URL, without port or userinfo. Pure."""
text = str(url or "").strip()
rest = text.split("://", 1)[1] if "://" in text else text
rest = rest.split("/", 1)[0]
if "@" in rest:
rest = rest.rsplit("@", 1)[1]
if rest.startswith("["):
return rest[1:].split("]", 1)[0].lower()
return rest.split(":", 1)[0].lower()
def is_private_host(host):
"""Whether a host is somewhere GitHub's delivery network cannot reach. Pure.
Deliberately a name and address test rather than a DNS lookup. A resolver
inside your network answers differently from GitHub's, so resolving here
would produce an answer about the wrong network, and a script that makes
DNS queries for every hook in an organization is a different kind of tool.
"""
name = str(host or "").strip().lower().strip(".")
if not name:
return False
if name in LOCAL_NAMES or name.endswith(LOCAL_SUFFIXES):
return True
if name in ("::1", "0:0:0:0:0:0:0:1"):
return True
if name.startswith(("fd", "fc", "fe80:")) and ":" in name:
return True
parts = name.split(".")
if len(parts) == 4 and all(p.isdigit() and len(p) <= 3 for p in parts):
octets = [int(p) for p in parts]
if any(o > 255 for o in octets):
return False
if octets[0] in (0, 127, 10):
return True
if octets[0] == 192 and octets[1] == 168:
return True
if octets[0] == 172 and 16 <= octets[1] <= 31:
return True
if octets[0] == 169 and octets[1] == 254:
return True
return False
def insecure_ssl_reads(hook):
"""The insecure_ssl value as text, or "" when absent. Pure.
Read as text on purpose. This script does not interpret the flag - that is
the certificate-verification question - it only needs to report what an
audit sampling the field would have seen.
"""
config = config_of(hook)
if "insecure_ssl" not in config:
return ""
return str(config["insecure_ssl"]).strip().lower()
def looks_compliant(hook):
"""Whether a plaintext hook reads as safe on the field audits sample. Pure.
The whole reason this problem survives a review that was genuinely carried
out: insecure_ssl is "0" on a hook with no TLS, because there is no
certificate to verify.
"""
return (scheme_of(raw_url(hook)) not in ("https", "")
and insecure_ssl_reads(hook) in ("0", "false"))
def has_secret(hook):
"""Whether the hook has a secret set. Pure. The value is never read."""
return "secret" in config_of(hook)
def classify(hook):
"""Sort one hook into a state and a sentence. Pure."""
ident = "hook %s" % (hook.get("id", "?") if isinstance(hook, dict) else "?")
url = raw_url(hook)
scheme = scheme_of(url)
if not url or not scheme:
return ("no-scheme",
"%s has no usable URL in its config, so nothing can be said "
"about how it delivers." % ident)
if scheme == "https":
if insecure_ssl_reads(hook) in ("1", "true"):
return ("encrypted-unverified",
"%s posts to %s over TLS, but with certificate "
"verification disabled. The transport is encrypted and "
"unauthenticated, which is a different question from this "
"one." % (ident, safe_url(url)))
return ("encrypted",
"%s posts to %s over TLS." % (ident, safe_url(url)))
if scheme != "http":
return ("unknown-scheme",
"%s posts to a %s:// URL, which is not a scheme GitHub "
"delivers to. Read the URL by hand." % (ident, scheme))
if is_private_host(host_of(url)):
return ("plaintext-unreachable",
"%s posts to %s, which GitHub cannot route to. This hook has "
"never delivered anything, and it is not leaking payloads "
"either." % (ident, safe_url(url)))
suffix = (" insecure_ssl reads \"%s\", which is what a hook with no TLS "
"at all always reads." % insecure_ssl_reads(hook)
if looks_compliant(hook) else "")
return ("plaintext",
"%s posts to %s over an unencrypted connection.%s"
% (ident, safe_url(url), suffix))
def repair(state, hook):
"""The change to make, printed as a whole config. Pure."""
if state == "plaintext":
rotate = (" and a new secret. Rotate: that secret has been signing "
"payloads on an open channel." if has_secret(hook)
else " and a secret, since this hook has none.")
return ("move the receiver behind HTTPS, then send the hook's full "
"config with the new URL, the content type%s The config is "
"replaced, not merged, and the secret you read back is a mask."
% rotate)
if state == "plaintext-unreachable":
return ("delete this hook, or point it at an endpoint GitHub can "
"reach over HTTPS. Its delivery log will be connection errors "
"and timeouts for as far back as the retention window goes.")
if state == "encrypted-unverified":
return ("this is the certificate-verification question rather than "
"the transport one. Fix the certificate, then set insecure_ssl "
"back to \"0\" as part of a full config update.")
if state in ("no-scheme", "unknown-scheme"):
return ("read the hook's URL by hand. A hook GitHub cannot parse a "
"scheme from is not delivering anything.")
return "nothing. This hook delivers over TLS."
def summarize(hooks):
"""Counts across every hook read. Pure."""
rows = [h for h in (hooks or []) if isinstance(h, dict)]
states = [classify(h)[0] for h in rows]
return {"total": len(rows),
"plaintext": states.count("plaintext"),
"unreachable": states.count("plaintext-unreachable"),
"encrypted": states.count("encrypted") + states.count("encrypted-unverified"),
"unreadable": states.count("no-scheme") + states.count("unknown-scheme")}
def get(session, path):
"""One GET. Returns (status, json-or-None)."""
url = API + path if path.startswith("/") else path
r = session.get(url, timeout=30)
try:
body = r.json()
except ValueError:
body = None
return r.status_code, body
def list_hooks(session, scope):
"""Hooks for a repo (owner/name) or an org (@org). Read only."""
path = ("/orgs/%s/hooks?per_page=100" % scope[1:] if scope.startswith("@")
else "/repos/%s/hooks?per_page=100" % scope)
status, body = get(session, path)
if status != 200 or not isinstance(body, list):
log.error("GET %s returned %d", path, status)
return []
return body
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--repo", action="append", default=[],
help="owner/name; repeatable")
ap.add_argument("--org", action="append", default=[],
help="organization login; repeatable")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN to a read-only token that can see the "
"repository's hooks")
return 2
scopes = list(args.repo) + ["@" + o for o in args.org]
if not scopes:
log.error("pass at least one --repo owner/name or --org login")
return 2
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
findings = []
for scope in scopes:
label = scope[1:] if scope.startswith("@") else scope
hooks = list_hooks(session, scope)
stats = summarize(hooks)
log.info("%d hook(s) on %s", stats["total"], label)
for hook in hooks:
state, detail = classify(hook)
findings.append({"scope": label, "hook_id": hook.get("id"),
"state": state, "detail": detail,
"url": safe_url(raw_url(hook)),
"looks_compliant": looks_compliant(hook)})
if state != "encrypted":
log.info("%s: %s", state, detail)
log.info("repair: %s", repair(state, hook))
if stats["plaintext"] == 0:
log.info("encrypted: no hook on %s delivers over plaintext HTTP "
"to a routable host", label)
print(json.dumps({"scopes": scopes, "findings": findings},
indent=2, default=str))
return 1 if any(f["state"] in LEAKING for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find webhooks that deliver over plaintext HTTP, and say which ones leak.
*
* Read only. Every call is a GET. Changing a hook's URL is a write and is not
* done here: the script prints the change, as a full config rather than a
* single field, because a webhook's config is replaced rather than merged and
* the secret you read back is a mask.
*
* Two things that look the same in this field are kept apart: http:// on a
* routable host means payloads are readable in transit, while http:// on a
* private address means GitHub cannot route there at all and the hook has
* never delivered anything.
*
* The secret is never printed, and neither is any query string or userinfo in
* a hook URL.
*
* Environment:
* GITHUB_TOKEN a read-only token that can see the repository's hooks
*
* Usage:
* node github-hook-transport.mjs acme-corp/api @acme-corp
*/
const API = 'https://api.github.com';
const UA = 'github-hook-transport/1.0';
const LOCAL_NAMES = ['localhost', 'localhost.localdomain', 'ip6-localhost'];
const LOCAL_SUFFIXES = ['.localhost', '.local', '.internal', '.lan',
'.home.arpa', '.localdomain'];
/** States that mean payloads are readable in transit. */
export const LEAKING = ['plaintext'];
/** The config object of a hook, or an empty object. Pure. */
export function configOf(hook) {
if (!hook || typeof hook !== 'object') return {};
const config = hook.config;
return config && typeof config === 'object' ? config : {};
}
/** The configured URL, trimmed, or ''. Pure. */
export function rawUrl(hook) {
return String(configOf(hook).url ?? '').trim();
}
/** A URL with its query string and any userinfo removed. Pure. */
export function safeUrl(url) {
let text = String(url ?? '').trim();
if (!text) return '';
text = text.split('?')[0].split('#')[0];
let scheme = '';
let rest = text;
if (text.includes('://')) {
const idx = text.indexOf('://');
scheme = text.slice(0, idx);
rest = text.slice(idx + 3);
}
if (rest.includes('@')) {
rest = `<redacted>@${rest.slice(rest.lastIndexOf('@') + 1)}`;
}
return scheme ? `${scheme}://${rest}` : rest;
}
/** The lower-cased scheme of a URL, or ''. Pure. */
export function schemeOf(url) {
const text = String(url ?? '').trim();
return text.includes('://') ? text.slice(0, text.indexOf('://')).toLowerCase() : '';
}
/** The lower-cased host of a URL, without port or userinfo. Pure. */
export function hostOf(url) {
const text = String(url ?? '').trim();
let rest = text.includes('://') ? text.slice(text.indexOf('://') + 3) : text;
rest = rest.split('/')[0];
if (rest.includes('@')) rest = rest.slice(rest.lastIndexOf('@') + 1);
if (rest.startsWith('[')) return rest.slice(1).split(']')[0].toLowerCase();
return rest.split(':')[0].toLowerCase();
}
/**
* Whether a host is somewhere GitHub's delivery network cannot reach. Pure.
* A name and address test rather than a DNS lookup: a resolver inside your
* network answers differently from GitHub's.
*/
export function isPrivateHost(host) {
const name = String(host ?? '').trim().toLowerCase().replace(/^\.+|\.+$/g, '');
if (!name) return false;
if (LOCAL_NAMES.includes(name)) return true;
if (LOCAL_SUFFIXES.some((s) => name.endsWith(s))) return true;
if (name === '::1' || name === '0:0:0:0:0:0:0:1') return true;
if (name.includes(':') && (name.startsWith('fd') || name.startsWith('fc') || name.startsWith('fe80:'))) {
return true;
}
const parts = name.split('.');
if (parts.length === 4 && parts.every((p) => /^[0-9]{1,3}$/.test(p))) {
const o = parts.map(Number);
if (o.some((n) => n > 255)) return false;
if ([0, 127, 10].includes(o[0])) return true;
if (o[0] === 192 && o[1] === 168) return true;
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true;
if (o[0] === 169 && o[1] === 254) return true;
}
return false;
}
/** The insecure_ssl value as text, or '' when absent. Pure. */
export function insecureSslReads(hook) {
const config = configOf(hook);
if (!('insecure_ssl' in config)) return '';
return String(config.insecure_ssl).trim().toLowerCase();
}
/** Whether a plaintext hook reads as safe on the field audits sample. Pure. */
export function looksCompliant(hook) {
const scheme = schemeOf(rawUrl(hook));
return scheme !== 'https' && scheme !== '' && ['0', 'false'].includes(insecureSslReads(hook));
}
/** Whether the hook has a secret set. Pure. The value is never read. */
export function hasSecret(hook) {
return 'secret' in configOf(hook);
}
/** Sort one hook into a state and a sentence. Pure. */
export function classify(hook) {
const ident = `hook ${(hook && typeof hook === 'object' ? hook.id : null) ?? '?'}`;
const url = rawUrl(hook);
const scheme = schemeOf(url);
if (!url || !scheme) {
return ['no-scheme',
`${ident} has no usable URL in its config, so nothing can be said about ` +
'how it delivers.'];
}
if (scheme === 'https') {
if (['1', 'true'].includes(insecureSslReads(hook))) {
return ['encrypted-unverified',
`${ident} posts to ${safeUrl(url)} over TLS, but with certificate ` +
'verification disabled. The transport is encrypted and ' +
'unauthenticated, which is a different question from this one.'];
}
return ['encrypted', `${ident} posts to ${safeUrl(url)} over TLS.`];
}
if (scheme !== 'http') {
return ['unknown-scheme',
`${ident} posts to a ${scheme}:// URL, which is not a scheme GitHub ` +
'delivers to. Read the URL by hand.'];
}
if (isPrivateHost(hostOf(url))) {
return ['plaintext-unreachable',
`${ident} posts to ${safeUrl(url)}, which GitHub cannot route to. This ` +
'hook has never delivered anything, and it is not leaking payloads either.'];
}
const suffix = looksCompliant(hook)
? ` insecure_ssl reads "${insecureSslReads(hook)}", which is what a hook ` +
'with no TLS at all always reads.'
: '';
return ['plaintext',
`${ident} posts to ${safeUrl(url)} over an unencrypted connection.${suffix}`];
}
/** The change to make, printed as a whole config. Pure. */
export function repair(state, hook) {
if (state === 'plaintext') {
const rotate = hasSecret(hook)
? ' and a new secret. Rotate: that secret has been signing payloads on an open channel.'
: ' and a secret, since this hook has none.';
return 'move the receiver behind HTTPS, then send the hook\'s full config ' +
`with the new URL, the content type${rotate} The config is replaced, ` +
'not merged, and the secret you read back is a mask.';
}
if (state === 'plaintext-unreachable') {
return 'delete this hook, or point it at an endpoint GitHub can reach ' +
'over HTTPS. Its delivery log will be connection errors and timeouts ' +
'for as far back as the retention window goes.';
}
if (state === 'encrypted-unverified') {
return 'this is the certificate-verification question rather than the ' +
'transport one. Fix the certificate, then set insecure_ssl back to "0" ' +
'as part of a full config update.';
}
if (state === 'no-scheme' || state === 'unknown-scheme') {
return "read the hook's URL by hand. A hook GitHub cannot parse a scheme " +
'from is not delivering anything.';
}
return 'nothing. This hook delivers over TLS.';
}
/** Counts across every hook read. Pure. */
export function summarize(hooks) {
const rows = (hooks ?? []).filter((h) => h && typeof h === 'object');
const states = rows.map((h) => classify(h)[0]);
const count = (name) => states.filter((s) => s === name).length;
return {
total: rows.length,
plaintext: count('plaintext'),
unreachable: count('plaintext-unreachable'),
encrypted: count('encrypted') + count('encrypted-unverified'),
unreadable: count('no-scheme') + count('unknown-scheme'),
};
}
async function get(token, path) {
const res = await fetch(path.startsWith('/') ? API + path : path, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
},
});
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
async function listHooks(token, scope) {
const path = scope.startsWith('@')
? `/orgs/${scope.slice(1)}/hooks?per_page=100`
: `/repos/${scope}/hooks?per_page=100`;
const { status, body } = await get(token, path);
if (status !== 200 || !Array.isArray(body)) {
console.error(`GET ${path} returned ${status}`);
return [];
}
return body;
}
async function main() {
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error('set GITHUB_TOKEN to a read-only token that can see the ' +
"repository's hooks");
process.exitCode = 2;
return;
}
const scopes = process.argv.slice(2);
if (scopes.length === 0) {
console.error('pass at least one owner/name, or @org for an organization');
process.exitCode = 2;
return;
}
const findings = [];
for (const scope of scopes) {
const label = scope.startsWith('@') ? scope.slice(1) : scope;
const hooks = await listHooks(token, scope);
const stats = summarize(hooks);
console.log(`${stats.total} hook(s) on ${label}`);
for (const hook of hooks) {
const [state, detail] = classify(hook);
findings.push({
scope: label,
hook_id: hook.id,
state,
detail,
url: safeUrl(rawUrl(hook)),
looks_compliant: looksCompliant(hook),
});
if (state !== 'encrypted') {
console.log(`${state}: ${detail}`);
console.log(`repair: ${repair(state, hook)}`);
}
}
if (stats.plaintext === 0) {
console.log(`encrypted: no hook on ${label} delivers over plaintext ` +
'HTTP to a routable host');
}
}
console.log(JSON.stringify({ scopes, findings }, null, 2));
process.exitCode = findings.some((f) => LEAKING.includes(f.state)) ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main() and set an exit code that fails the suite even as
// every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two separations carry this note and both are tested directly: a plaintext hook on a routable host is a leak, the same scheme on localhost or a private range is a hook that has never delivered, and neither is allowed to be reported as the other. After that, the small function whose only job is to name the trap — an http:// hook whose insecure_ssl reads 0 — the URL redaction that has to survive both a query string and a user:pass@ prefix, and the private-range boundaries where 172.15 and 172.32 are public and everything between them is not.
from github_hook_transport import (
classify, host_of, is_private_host, looks_compliant, repair, safe_url,
scheme_of, summarize,
)
OPEN = {"id": 1, "config": {"url": "http://hooks.acme.io/github",
"insecure_ssl": "0", "secret": "********",
"content_type": "json"}}
LOCAL = {"id": 2, "config": {"url": "http://localhost:3000/hooks",
"insecure_ssl": "0"}}
TLS = {"id": 3, "config": {"url": "https://hooks.acme.io/github",
"insecure_ssl": "0", "secret": "********"}}
UNVERIFIED = {"id": 4, "config": {"url": "https://hooks.acme.io/github",
"insecure_ssl": "1", "secret": "********"}}
def test_plaintext_on_a_routable_host_is_the_finding():
state, detail = classify(OPEN)
assert state == "plaintext"
assert "unencrypted connection" in detail
assert "signing payloads on an open channel" in repair(state, OPEN)
def test_plaintext_on_localhost_is_a_dead_hook_not_a_leak():
state, detail = classify(LOCAL)
assert state == "plaintext-unreachable"
assert "never delivered anything" in detail
assert "delete this hook" in repair(state, LOCAL)
def test_the_certificate_question_is_handed_to_the_other_note():
state, detail = classify(UNVERIFIED)
assert state == "encrypted-unverified"
assert "different question" in detail
assert classify(TLS)[0] == "encrypted"
def test_the_compliant_looking_field_is_named_in_the_finding():
assert looks_compliant(OPEN)
assert not looks_compliant(TLS)
assert not looks_compliant(UNVERIFIED)
assert "what a hook with no TLS at all always reads" in classify(OPEN)[1]
def test_a_plaintext_hook_with_no_insecure_ssl_field_is_still_the_finding():
hook = {"id": 5, "config": {"url": "http://hooks.acme.io/github"}}
assert not looks_compliant(hook)
assert classify(hook)[0] == "plaintext"
def test_the_private_ranges_stop_where_they_should():
assert is_private_host("10.0.0.1")
assert is_private_host("192.168.1.7")
assert is_private_host("172.16.0.1")
assert is_private_host("172.31.255.254")
assert is_private_host("127.0.0.1")
assert is_private_host("169.254.169.254")
assert not is_private_host("172.15.0.1")
assert not is_private_host("172.32.0.1")
assert not is_private_host("8.8.8.8")
assert not is_private_host("hooks.acme.io")
def test_local_names_and_ipv6_loopback_count_as_unreachable():
assert is_private_host("localhost")
assert is_private_host("build-01.internal")
assert is_private_host("printer.local")
assert is_private_host("::1")
assert is_private_host("fd00::1")
assert not is_private_host("")
assert not is_private_host(None)
def test_the_printed_url_survives_a_query_string_and_a_userinfo_prefix():
assert safe_url("http://hooks.acme.io/github?token=abc123") == "http://hooks.acme.io/github"
assert safe_url("https://bot:hunter2@hooks.acme.io/x") == "https://<redacted>@hooks.acme.io/x"
assert "hunter2" not in safe_url("https://bot:hunter2@hooks.acme.io/x")
assert safe_url("") == ""
def test_the_host_is_parsed_out_of_the_shapes_a_url_arrives_in():
assert host_of("http://hooks.acme.io:8080/github") == "hooks.acme.io"
assert host_of("http://bot:pw@10.0.0.4/hooks") == "10.0.0.4"
assert host_of("http://[::1]:3000/hooks") == "::1"
assert scheme_of("HTTP://hooks.acme.io") == "http"
assert scheme_of("hooks.acme.io") == ""
def test_a_hook_with_no_url_is_not_counted_either_way():
state, _ = classify({"id": 6, "config": {}})
assert state == "no-scheme"
assert classify({"id": 7, "config": {"url": "ftp://x.example/h"}})[0] == "unknown-scheme"
def test_the_summary_separates_leaking_from_unreachable():
stats = summarize([OPEN, LOCAL, TLS, UNVERIFIED])
assert stats == {"total": 4, "plaintext": 1, "unreachable": 1,
"encrypted": 2, "unreadable": 0}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
classify, hostOf, isPrivateHost, looksCompliant, repair, safeUrl,
schemeOf, summarize,
} from './github-hook-transport.mjs';
const OPEN = {
id: 1,
config: {
url: 'http://hooks.acme.io/github', insecure_ssl: '0',
secret: '********', content_type: 'json',
},
};
const LOCAL = {
id: 2,
config: { url: 'http://localhost:3000/hooks', insecure_ssl: '0' },
};
const TLS = {
id: 3,
config: { url: 'https://hooks.acme.io/github', insecure_ssl: '0', secret: '********' },
};
const UNVERIFIED = {
id: 4,
config: { url: 'https://hooks.acme.io/github', insecure_ssl: '1', secret: '********' },
};
test('plaintext on a routable host is the finding', () => {
const [state, detail] = classify(OPEN);
assert.equal(state, 'plaintext');
assert.match(detail, /unencrypted connection/);
assert.match(repair(state, OPEN), /signing payloads on an open channel/);
});
test('plaintext on localhost is a dead hook not a leak', () => {
const [state, detail] = classify(LOCAL);
assert.equal(state, 'plaintext-unreachable');
assert.match(detail, /never delivered anything/);
assert.match(repair(state, LOCAL), /delete this hook/);
});
test('the certificate question is handed to the other note', () => {
const [state, detail] = classify(UNVERIFIED);
assert.equal(state, 'encrypted-unverified');
assert.match(detail, /different question/);
assert.equal(classify(TLS)[0], 'encrypted');
});
test('the compliant looking field is named in the finding', () => {
assert.ok(looksCompliant(OPEN));
assert.ok(!looksCompliant(TLS));
assert.ok(!looksCompliant(UNVERIFIED));
assert.match(classify(OPEN)[1], /what a hook with no TLS at all always reads/);
});
test('a plaintext hook with no insecure_ssl field is still the finding', () => {
const hook = { id: 5, config: { url: 'http://hooks.acme.io/github' } };
assert.ok(!looksCompliant(hook));
assert.equal(classify(hook)[0], 'plaintext');
});
test('the private ranges stop where they should', () => {
assert.ok(isPrivateHost('10.0.0.1'));
assert.ok(isPrivateHost('192.168.1.7'));
assert.ok(isPrivateHost('172.16.0.1'));
assert.ok(isPrivateHost('172.31.255.254'));
assert.ok(isPrivateHost('127.0.0.1'));
assert.ok(isPrivateHost('169.254.169.254'));
assert.ok(!isPrivateHost('172.15.0.1'));
assert.ok(!isPrivateHost('172.32.0.1'));
assert.ok(!isPrivateHost('8.8.8.8'));
assert.ok(!isPrivateHost('hooks.acme.io'));
});
test('local names and ipv6 loopback count as unreachable', () => {
assert.ok(isPrivateHost('localhost'));
assert.ok(isPrivateHost('build-01.internal'));
assert.ok(isPrivateHost('printer.local'));
assert.ok(isPrivateHost('::1'));
assert.ok(isPrivateHost('fd00::1'));
assert.ok(!isPrivateHost(''));
assert.ok(!isPrivateHost(null));
});
test('the printed url survives a query string and a userinfo prefix', () => {
assert.equal(safeUrl('http://hooks.acme.io/github?token=abc123'),
'http://hooks.acme.io/github');
assert.equal(safeUrl('https://bot:hunter2@hooks.acme.io/x'),
'https://<redacted>@hooks.acme.io/x');
assert.ok(!safeUrl('https://bot:hunter2@hooks.acme.io/x').includes('hunter2'));
assert.equal(safeUrl(''), '');
});
test('the host is parsed out of the shapes a url arrives in', () => {
assert.equal(hostOf('http://hooks.acme.io:8080/github'), 'hooks.acme.io');
assert.equal(hostOf('http://bot:pw@10.0.0.4/hooks'), '10.0.0.4');
assert.equal(hostOf('http://[::1]:3000/hooks'), '::1');
assert.equal(schemeOf('HTTP://hooks.acme.io'), 'http');
assert.equal(schemeOf('hooks.acme.io'), '');
});
test('a hook with no url is not counted either way', () => {
assert.equal(classify({ id: 6, config: {} })[0], 'no-scheme');
assert.equal(classify({ id: 7, config: { url: 'ftp://x.example/h' } })[0],
'unknown-scheme');
});
test('the summary separates leaking from unreachable', () => {
assert.deepEqual(summarize([OPEN, LOCAL, TLS, UNVERIFIED]), {
total: 4, plaintext: 1, unreachable: 1, encrypted: 2, unreadable: 0,
});
});
FAQ
The payload is signed. Does that not make plaintext acceptable?
No, because a signature is about authenticity and not about secrecy. HMAC proves the payload came from GitHub and has not been altered; it does not hide a single byte of it, and the signature travels in a header of the same unencrypted request. Anyone on the path reads the commit messages, branch names, issue bodies and logins in full, and can replay the exact request at your receiver afterwards - it will verify, because it is genuine. Signing solves forgery. It does not solve reading.
Why does insecure_ssl still say 0 on a plaintext hook?
Because that field is about verifying a certificate during a TLS handshake, and an http:// hook never performs one. There is nothing to verify, so the field stays at its default and reports the value everybody is looking for. That is the specific reason this survives audits: a checklist that samples insecure_ssl sees a compliant hook. The script names that combination explicitly in its output rather than leaving the reader to notice it.
Our receiver is inside the network on a plain port. What are the options?
If GitHub is delivering to it successfully, it is not inside the network in any useful sense - the route from GitHub's delivery infrastructure to that host exists, and so does the route for everyone in between. The workable shape is a small public endpoint that terminates a real certificate, verifies the signature, and forwards inwards. If GitHub genuinely cannot reach the host, the hook has never worked and the script reports it as unreachable instead.
Is a hook pointed at localhost or a tunnel actually a problem?
It is a different problem. GitHub cannot route to a loopback or private address, so nothing is leaking and nothing is arriving either: the delivery log will be connection errors for as far back as it goes. What makes it worth reporting is that it sits in the hook list looking like a working integration, so somebody counts it, and a handler somewhere has been waiting for events since the day the tunnel was closed. Delete it or repoint it.
How much of the repository actually goes over the wire?
More than people expect. A push payload carries the branch, the commits with their messages and author details, and the list of files added, removed and modified. An issue or pull request payload carries the entire body and title. A comment payload carries the comment. Over an open channel, on a private repository, that is a readable feed of your development work, and the volume depends entirely on which events the hook subscribes to - which is worth reading at the same time.
Related field notes
- Certificate verification switched off on the hook
- A webhook with no secret sends no signature
- A receiver still checking the SHA-1 signature
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Repository webhooks — GitHub REST API
- Validating webhook deliveries — GitHub Docs
- Organization webhooks — GitHub REST API
- About webhooks — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.