Diagnostic GitHub API
SSL verification is switched off on the webhook
The URL starts with https. The delivery log is clean, every attempt is a 200, and the hook has a secret so the payloads are signed. Everything a review asks about is green. One field in the same config object says "insecure_ssl": "1", which means GitHub has not looked at your certificate since somebody set that during the initial deploy in 2023.
Read GET /repos/{owner}/{repo}/hooks and look at config.insecure_ssl. "1" means GitHub skips verification of your endpoint's TLS certificate; "0" means it checks. The value is a string, which matters, because "0" is a non-empty string and a truthy test on it reports every correctly configured hook as insecure.
Deliveries succeed either way, so nothing surfaces this. What you have lost is not encryption but identity: the connection is still TLS, but GitHub will accept whatever certificate is presented, so anything that can answer for your hostname — a stale DNS record somebody else now owns, a compromised load balancer, a machine on the path — receives your payloads, correctly signed with your secret, and can replay them at your real endpoint afterwards. Fix the certificate first, set the flag back to "0", then rotate the secret.
The problem in plain words
It gets switched on for an honest reason and stays on for a stupid one. Somebody is bringing the receiver up on staging behind a self-signed certificate, or an internal CA that GitHub has never heard of, and deliveries fail with a TLS error. There is a checkbox that makes the error go away, they tick it, the integration starts working, and the ticket closes. The certificate gets fixed a fortnight later and nobody goes back to untick the box, because by then nothing is broken and the box is two screens deep in a settings page nobody has a reason to open.
After that it is invisible by construction. Every other signal says the integration is healthy: deliveries succeed, the log is clean, the URL is https, the payloads are signed. A security review that looks at the URL scheme sees TLS and moves on. The one field that says otherwise is a string of one character in an object most people never print, and it is spelled in a way that reads as reassuring: insecure_ssl is "0" when things are fine, so a skim looking for a scary value finds a zero and relaxes.
Then the repair goes wrong in its own particular way. Setting the flag back is a config update, and a webhook's config is replaced rather than merged, so the natural instinct — read the config, change one field, write it back — sends back the masked secret, which means the hook's secret becomes the literal string of asterisks or is dropped entirely. Signature verification breaks on the next delivery and the change gets reverted by somebody who concludes the flag was load bearing.
Why it happens
The flag disables verification, not encryption. The bytes are still inside TLS, so this is not the same as posting in the clear. What goes away is the guarantee that the endpoint on the other end is yours. An attacker no longer needs to read the wire; they need to be answered instead of you, which is a much older and much easier trick than breaking a cipher.
A signed payload does not protect you here. The signature proves GitHub sent it. It says nothing about who received it, and it travels in the same request, so an impersonating endpoint collects a stream of genuine, correctly signed payloads. Those payloads carry repository names, branch names, commit messages, issue bodies and pull request diffsets depending on the events, and they can be replayed against your real receiver later, because they will verify perfectly.
It is a string, and the wrong test on it inverts the answer. config.insecure_ssl comes back as "0" or "1", and both are non-empty strings. Anyone who writes if config["insecure_ssl"] flags every safe hook in the organization and learns to ignore the tool. Parse the value; do not test it.
Turning it off is not the whole repair. Setting the flag back to "0" while the certificate is still bad turns a silent exposure into a wall of failed deliveries, which is better but is not what anyone wants at four on a Friday. Install a certificate the public trust store accepts first, confirm it independently, then flip the flag. If the endpoint is genuinely internal and cannot have a public certificate, the fix is a public gateway in front of it, not a permanent exemption.
And the secret has to be rotated afterwards. For as long as verification was off, any endpoint that managed to be answered instead of yours received payloads signed with your secret. The secret itself is not in the payload, so it was not disclosed — but the window during which the traffic could be collected and replayed was real, and rotation is the only thing that closes it. Rotate by sending the full config with a new secret, not by patching one field.
The fix, as a flow
One field, three readable states, and a parse rather than a test. Both values of insecure_ssl are non-empty strings, so a truthy check reports every correctly configured hook as insecure and the tool gets switched off after its first run. The branch also gives one row away: a hook with no TLS at all belongs to the plaintext note, because a field about certificate verification describes nothing on a handshake that never happens. What is left is the finding, and the repair it prints is a whole config, because the config is replaced and the secret comes back masked.
How to fix it
Read the flag on every hook, in every scope
GET /repos/{owner}/{repo}/hooks and GET /orgs/{org}/hooks both return config.insecure_ssl. A GitHub App's own hook exposes the same field at GET /app/hook/config. Read all the scopes that can reach the repository, because the one that was set up by hand during an incident is rarely the one anybody remembers.
Parse the value rather than testing it
Accept "1", 1 and true as on; "0", 0 and false as off; anything else as unreadable. A truthy test reports "0" as insecure, which is the failure mode that gets a security tool switched off after its first run.
Skip the hooks that are a different problem
A hook whose URL is http:// performs no TLS handshake at all, so insecure_ssl describes nothing and is not the finding to report there. The script says so and points at the other question, rather than counting a plaintext hook as a certificate problem and letting the real one hide inside the number.
Date it from updated_at
updated_at moves on any config change, so a hook that has not been touched in eleven months has had verification off for at least that long. It is a lower bound rather than a start date, and the script says at least for that reason, but it is usually enough to answer the only question anybody asks next.
Fix the certificate, then the flag, then the secret
In that order. Install a certificate that chains to a public root, verify it from outside your network, then send the hook's full config with insecure_ssl set to "0" — the whole object, including the URL, the content type and a freshly generated secret, because the config is replaced and the secret you read back is a mask.
How to check it worked
The re-read shows "0", deliveries keep succeeding, and a request to the endpoint from a machine with a normal trust store completes without a certificate warning.
GITHUB_TOKEN=$GH_READONLY python3 github_hook_ssl_verification.py --repo acme-corp/api
# 3 hook(s) on acme-corp/api
# verification-off: hook 512334455 posts to https://hooks.acme.io/github with
# certificate verification disabled, and has not been edited for at least 341
# day(s). Deliveries succeed, so nothing else reports this.
# repair: install a certificate that chains to a public root, confirm it from
# outside your network, then send the hook's full config with insecure_ssl "0"
# and a new secret. The config is replaced, not merged, and the secret you read
# back is a mask.
# after the change
# verified: hook 512334455 posts to https://hooks.acme.io/github and GitHub
# checks the certificate
The full code
One GET per scope, and the whole finding is one field. The care goes into not getting a two-value string wrong in either direction: a truthy test calls every safe hook insecure, and a falsy test on an absent field calls an unknown hook safe, so the parse has three outcomes and the third is reported rather than rounded. The rest of the pure core keeps this note out of its neighbour's territory — a hook with no TLS at all is handed to the plaintext question instead of being counted here — and dates the exposure as a lower bound from the one timestamp that exists. The repair is printed, and it is printed as a whole config rather than a single field, because the config is replaced and the secret comes back masked.
"""Find webhooks GitHub delivers to without checking the TLS certificate.
Read only. Every call is a GET. Changing the flag is a write and this script
does not do it: it prints the request, 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.
config.insecure_ssl set to "1" tells GitHub to skip verification of the
endpoint's certificate. The connection is still TLS, so this is not the same as
posting in the clear; what is lost is the guarantee that the endpoint is yours.
Anything that can be answered instead of you receives correctly signed payloads
and can replay them afterwards.
Deliveries succeed the whole time, which is why nothing else reports this.
The secret is never printed. Its presence is read only to decide whether the
repair needs to mention rotation.
Environment:
GITHUB_TOKEN a read-only token that can see the repository's hooks
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_hook_ssl_verification")
API = "https://api.github.com"
UA = "github-hook-ssl-verification/1.0"
# The two-value string, in every spelling it arrives in. Both "0" and "1" are
# non-empty strings, so a truthy test on this field reports every correctly
# configured hook as insecure. Parse it; do not test it.
INSECURE_ON = ("1", "true", "yes", "on")
INSECURE_OFF = ("0", "false", "no", "off")
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 insecure_flag(hook):
"""Three-state read of insecure_ssl: on, off or unknown. Pure.
on means GitHub does not check the certificate
off means it does
unknown means the field was absent or unreadable, which is reported rather
than rounded to either answer
"""
config = config_of(hook)
if "insecure_ssl" not in config:
return "unknown"
raw = config["insecure_ssl"]
if isinstance(raw, bool):
return "on" if raw else "off"
if isinstance(raw, (int, float)):
return "on" if raw else "off"
text = str(raw).strip().lower()
if text in INSECURE_ON:
return "on"
if text in INSECURE_OFF:
return "off"
return "unknown"
def scheme_of(hook):
"""The URL scheme of a hook, lower-cased, or "" when there is none. Pure."""
url = str(config_of(hook).get("url") or "").strip()
if "://" not in url:
return ""
return url.split("://", 1)[0].lower()
def endpoint(hook):
"""The hook's URL with any query string dropped. Pure.
A URL is printable, a query string is not reliably so: hooks created by
hand sometimes carry a token in one, and this script prints its findings.
"""
url = str(config_of(hook).get("url") or "").strip()
return url.split("?", 1)[0] if url else "an unset URL"
def has_secret(hook):
"""Whether the hook has a secret set. Pure. The value is never read."""
return "secret" in config_of(hook)
def parsed_time(text):
"""An ISO 8601 timestamp as an aware datetime, or None. Pure."""
raw = str(text or "").strip()
if not raw or raw.lower() in ("null", "none"):
return None
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
when = datetime.fromisoformat(raw)
except ValueError:
return None
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
def unchanged_days(hook, now):
"""Days since the hook config was last edited, or None. Pure.
A lower bound on how long verification has been off, never a start date:
updated_at moves on any change to the hook, so it says "not since then"
rather than "since then".
"""
if not isinstance(hook, dict) or now is None:
return None
when = parsed_time(hook.get("updated_at"))
if when is None:
return None
return (now - when).days
def classify(hook, now=None):
"""Sort one hook into a state and a sentence. Pure.
A hook with no TLS at all is handed to the plaintext question rather than
counted here. insecure_ssl describes a handshake that hook never performs,
and folding the two together lets the real finding hide inside a number.
"""
ident = "hook %s" % (hook.get("id", "?") if isinstance(hook, dict) else "?")
scheme = scheme_of(hook)
flag = insecure_flag(hook)
if not scheme:
return ("no-url",
"%s has no usable URL in its config, so there is nothing to "
"verify a certificate against." % ident)
if scheme != "https":
return ("not-applicable",
"%s posts to a %s:// URL, so no certificate is checked because "
"no TLS handshake happens. insecure_ssl is not the finding "
"here; the scheme is." % (ident, scheme))
if flag == "on":
age = unchanged_days(hook, now)
return ("verification-off",
"%s posts to %s with certificate verification disabled%s. "
"Deliveries succeed, so nothing else reports this."
% (ident, endpoint(hook),
", and has not been edited for at least %d day(s)" % age
if age is not None else ""))
if flag == "unknown":
return ("flag-unreadable",
"%s does not report a readable insecure_ssl value. Read it in "
"the hook's settings rather than assuming either answer."
% ident)
return ("verified",
"%s posts to %s and GitHub checks the certificate."
% (ident, endpoint(hook)))
def repair(state, hook):
"""The change to make, printed as a whole config. Pure.
Never a single-field update. A webhook's config is replaced rather than
merged, and config.secret comes back masked, so a read-modify-write of one
field writes the mask back as the secret or drops it.
"""
if state == "verification-off":
rotate = (" and a new secret" if has_secret(hook)
else " and a secret, since this hook has none")
return ("install a certificate that chains to a public root, confirm "
"it from outside your network, then send the hook's full "
"config with insecure_ssl \"0\"%s. The config is replaced, not "
"merged, and the secret you read back is a mask." % rotate)
if state == "not-applicable":
return ("move the receiver behind HTTPS and change the URL. Until "
"then insecure_ssl is a field about a handshake this hook "
"never performs.")
if state == "flag-unreadable":
return ("open the hook's settings and read the SSL verification "
"setting by hand.")
if state == "no-url":
return ("set a URL on this hook, or delete it. A hook with no endpoint "
"delivers nothing and hides in every audit that counts hooks.")
return "nothing. GitHub verifies this endpoint's certificate."
def summarize(hooks, now=None):
"""Counts across every hook read. Pure."""
rows = [h for h in (hooks or []) if isinstance(h, dict)]
states = [classify(h, now)[0] for h in rows]
return {"total": len(rows),
"verification_off": states.count("verification-off"),
"verified": states.count("verified"),
"plaintext": states.count("not-applicable"),
"unreadable": states.count("flag-unreadable") + states.count("no-url")}
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,
})
now = datetime.now(timezone.utc)
findings = []
for scope in scopes:
label = scope[1:] if scope.startswith("@") else scope
hooks = list_hooks(session, scope)
stats = summarize(hooks, now)
log.info("%d hook(s) on %s", stats["total"], label)
for hook in hooks:
state, detail = classify(hook, now)
findings.append({"scope": label, "hook_id": hook.get("id"),
"state": state, "detail": detail,
"url": endpoint(hook),
"secret_set": has_secret(hook)})
if state != "verified":
log.info("%s: %s", state, detail)
log.info("repair: %s", repair(state, hook))
if stats["verification_off"] == 0:
log.info("verified: no hook on %s has certificate verification "
"disabled", label)
print(json.dumps({"scopes": scopes, "findings": findings},
indent=2, default=str))
return 1 if any(f["state"] == "verification-off" for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find webhooks GitHub delivers to without checking the TLS certificate.
*
* Read only. Every call is a GET. Changing the flag is a write and is not done
* here: the script prints the request, 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.
*
* config.insecure_ssl set to "1" tells GitHub to skip verification of the
* endpoint's certificate. The connection is still TLS; what is lost is the
* guarantee that the endpoint is yours.
*
* The secret is never printed. Its presence is read only to decide whether the
* repair needs to mention rotation.
*
* Environment:
* GITHUB_TOKEN a read-only token that can see the repository's hooks
*
* Usage:
* node github-hook-ssl-verification.mjs acme-corp/api @acme-corp
*/
const API = 'https://api.github.com';
const UA = 'github-hook-ssl-verification/1.0';
const INSECURE_ON = ['1', 'true', 'yes', 'on'];
const INSECURE_OFF = ['0', 'false', 'no', 'off'];
/** 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 : {};
}
/**
* Three-state read of insecure_ssl: on, off or unknown. Pure.
* Both '0' and '1' are non-empty strings, so a truthy test reports every
* correctly configured hook as insecure.
*/
export function insecureFlag(hook) {
const config = configOf(hook);
if (!('insecure_ssl' in config)) return 'unknown';
const raw = config.insecure_ssl;
if (typeof raw === 'boolean') return raw ? 'on' : 'off';
if (typeof raw === 'number') return raw ? 'on' : 'off';
const text = String(raw ?? '').trim().toLowerCase();
if (INSECURE_ON.includes(text)) return 'on';
if (INSECURE_OFF.includes(text)) return 'off';
return 'unknown';
}
/** The URL scheme of a hook, lower-cased, or '' when there is none. Pure. */
export function schemeOf(hook) {
const url = String(configOf(hook).url ?? '').trim();
if (!url.includes('://')) return '';
return url.split('://')[0].toLowerCase();
}
/** The hook's URL with any query string dropped. Pure. */
export function endpoint(hook) {
const url = String(configOf(hook).url ?? '').trim();
return url ? url.split('?')[0] : 'an unset URL';
}
/** Whether the hook has a secret set. Pure. The value is never read. */
export function hasSecret(hook) {
return 'secret' in configOf(hook);
}
/** An ISO 8601 timestamp as epoch milliseconds, or null. Pure. */
export function parsedTime(text) {
const raw = String(text ?? '').trim();
if (!raw || ['null', 'none'].includes(raw.toLowerCase())) return null;
const ms = Date.parse(raw);
return Number.isNaN(ms) ? null : ms;
}
/** Days since the hook config was last edited, or null. Pure. A lower bound. */
export function unchangedDays(hook, nowMs) {
if (!hook || typeof hook !== 'object' || nowMs === null || nowMs === undefined) {
return null;
}
const when = parsedTime(hook.updated_at);
if (when === null) return null;
return Math.floor((nowMs - when) / 86400000);
}
/** Sort one hook into a state and a sentence. Pure. */
export function classify(hook, nowMs = null) {
const ident = `hook ${(hook && typeof hook === 'object' ? hook.id : null) ?? '?'}`;
const scheme = schemeOf(hook);
const flag = insecureFlag(hook);
if (!scheme) {
return ['no-url',
`${ident} has no usable URL in its config, so there is nothing to ` +
'verify a certificate against.'];
}
if (scheme !== 'https') {
return ['not-applicable',
`${ident} posts to a ${scheme}:// URL, so no certificate is checked ` +
'because no TLS handshake happens. insecure_ssl is not the finding ' +
'here; the scheme is.'];
}
if (flag === 'on') {
const age = unchangedDays(hook, nowMs);
return ['verification-off',
`${ident} posts to ${endpoint(hook)} with certificate verification ` +
`disabled${age !== null ? `, and has not been edited for at least ${age} day(s)` : ''}. ` +
'Deliveries succeed, so nothing else reports this.'];
}
if (flag === 'unknown') {
return ['flag-unreadable',
`${ident} does not report a readable insecure_ssl value. Read it in the ` +
"hook's settings rather than assuming either answer."];
}
return ['verified',
`${ident} posts to ${endpoint(hook)} and GitHub checks the certificate.`];
}
/** The change to make, printed as a whole config. Pure. */
export function repair(state, hook) {
if (state === 'verification-off') {
const rotate = hasSecret(hook)
? ' and a new secret'
: ' and a secret, since this hook has none';
return 'install a certificate that chains to a public root, confirm it ' +
"from outside your network, then send the hook's full config with " +
`insecure_ssl "0"${rotate}. The config is replaced, not merged, and the ` +
'secret you read back is a mask.';
}
if (state === 'not-applicable') {
return 'move the receiver behind HTTPS and change the URL. Until then ' +
'insecure_ssl is a field about a handshake this hook never performs.';
}
if (state === 'flag-unreadable') {
return "open the hook's settings and read the SSL verification setting by hand.";
}
if (state === 'no-url') {
return 'set a URL on this hook, or delete it. A hook with no endpoint ' +
'delivers nothing and hides in every audit that counts hooks.';
}
return "nothing. GitHub verifies this endpoint's certificate.";
}
/** Counts across every hook read. Pure. */
export function summarize(hooks, nowMs = null) {
const rows = (hooks ?? []).filter((h) => h && typeof h === 'object');
const states = rows.map((h) => classify(h, nowMs)[0]);
const count = (name) => states.filter((s) => s === name).length;
return {
total: rows.length,
verification_off: count('verification-off'),
verified: count('verified'),
plaintext: count('not-applicable'),
unreadable: count('flag-unreadable') + count('no-url'),
};
}
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 now = Date.now();
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, now);
console.log(`${stats.total} hook(s) on ${label}`);
for (const hook of hooks) {
const [state, detail] = classify(hook, now);
findings.push({
scope: label,
hook_id: hook.id,
state,
detail,
url: endpoint(hook),
secret_set: hasSecret(hook),
});
if (state !== 'verified') {
console.log(`${state}: ${detail}`);
console.log(`repair: ${repair(state, hook)}`);
}
}
if (stats.verification_off === 0) {
console.log(`verified: no hook on ${label} has certificate verification disabled`);
}
}
console.log(JSON.stringify({ scopes, findings }, null, 2));
process.exitCode = findings.some((f) => f.state === 'verification-off') ? 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
The first test is the one that decides whether anybody keeps using the tool: "0" is a non-empty string, so a truthy test on insecure_ssl marks every correctly configured hook as insecure and the second run never happens. The rest pin the other spellings the field arrives in, the refusal to guess when it is absent, the boundary with the plaintext note — an http:// hook is handed over rather than counted — the age being reported as a lower bound, and a repair that describes a whole config because a single-field update writes the masked secret back.
from datetime import datetime, timezone
from github_hook_ssl_verification import (
classify, endpoint, has_secret, insecure_flag, repair, scheme_of,
summarize, unchanged_days,
)
NOW = datetime(2026, 8, 30, 12, 0, 0, tzinfo=timezone.utc)
OPEN = {"id": 1, "updated_at": "2025-09-23T08:00:00Z",
"config": {"url": "https://hooks.acme.io/github", "insecure_ssl": "1",
"secret": "********", "content_type": "json"}}
SAFE = {"id": 2, "updated_at": "2026-08-01T08:00:00Z",
"config": {"url": "https://hooks.acme.io/github", "insecure_ssl": "0",
"secret": "********", "content_type": "json"}}
PLAIN = {"id": 3, "updated_at": "2026-08-01T08:00:00Z",
"config": {"url": "http://hooks.acme.io/github", "insecure_ssl": "0"}}
def test_the_string_zero_is_not_a_finding():
assert insecure_flag(SAFE) == "off"
assert insecure_flag({"config": {"insecure_ssl": 0}}) == "off"
assert insecure_flag({"config": {"insecure_ssl": False}}) == "off"
assert classify(SAFE, NOW)[0] == "verified"
def test_every_spelling_of_on_is_on():
assert insecure_flag(OPEN) == "on"
assert insecure_flag({"config": {"insecure_ssl": 1}}) == "on"
assert insecure_flag({"config": {"insecure_ssl": True}}) == "on"
assert insecure_flag({"config": {"insecure_ssl": "true"}}) == "on"
def test_an_absent_flag_is_unknown_rather_than_either_answer():
assert insecure_flag({"config": {"url": "https://x.example"}}) == "unknown"
assert insecure_flag({"config": {"insecure_ssl": "maybe"}}) == "unknown"
assert insecure_flag({"id": 4}) == "unknown"
state, detail = classify({"id": 4, "config": {"url": "https://x.example"}}, NOW)
assert state == "flag-unreadable"
assert "rather than assuming" in detail
def test_a_plaintext_hook_is_handed_to_the_other_question():
state, detail = classify(PLAIN, NOW)
assert state == "not-applicable"
assert "the scheme is" in detail
assert "behind HTTPS" in repair(state, PLAIN)
def test_the_finding_names_the_endpoint_and_dates_it_as_a_lower_bound():
state, detail = classify(OPEN, NOW)
assert state == "verification-off"
assert "https://hooks.acme.io/github" in detail
assert "at least 341 day(s)" in detail
assert unchanged_days(OPEN, NOW) == 341
def test_a_hook_with_no_url_is_its_own_state():
state, _ = classify({"id": 5, "config": {"insecure_ssl": "1"}}, NOW)
assert state == "no-url"
def test_the_printed_url_drops_any_query_string():
hook = {"id": 6, "config": {"url": "https://hooks.acme.io/github?token=abc123"}}
assert endpoint(hook) == "https://hooks.acme.io/github"
assert scheme_of(hook) == "https"
assert scheme_of({"config": {"url": "not-a-url"}}) == ""
def test_the_repair_is_a_whole_config_not_one_field():
text = repair("verification-off", OPEN)
assert "full" in text
assert "replaced, not merged" in text
assert "new secret" in text
def test_a_hook_with_no_secret_gets_told_to_set_one():
hookless = {"id": 7, "config": {"url": "https://x.example", "insecure_ssl": "1"}}
assert not has_secret(hookless)
assert "since this hook has none" in repair("verification-off", hookless)
def test_the_summary_keeps_the_plaintext_hooks_out_of_the_finding_count():
stats = summarize([OPEN, SAFE, PLAIN], NOW)
assert stats == {"total": 3, "verification_off": 1, "verified": 1,
"plaintext": 1, "unreadable": 0}
def test_an_unparseable_timestamp_produces_no_age():
assert unchanged_days({"updated_at": "whenever"}, NOW) is None
assert unchanged_days({"id": 1}, NOW) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
classify, endpoint, hasSecret, insecureFlag, repair, schemeOf,
summarize, unchangedDays,
} from './github-hook-ssl-verification.mjs';
const NOW = Date.parse('2026-08-30T12:00:00Z');
const OPEN = {
id: 1, updated_at: '2025-09-23T08:00:00Z',
config: {
url: 'https://hooks.acme.io/github', insecure_ssl: '1',
secret: '********', content_type: 'json',
},
};
const SAFE = {
id: 2, updated_at: '2026-08-01T08:00:00Z',
config: {
url: 'https://hooks.acme.io/github', insecure_ssl: '0',
secret: '********', content_type: 'json',
},
};
const PLAIN = {
id: 3, updated_at: '2026-08-01T08:00:00Z',
config: { url: 'http://hooks.acme.io/github', insecure_ssl: '0' },
};
test('the string zero is not a finding', () => {
assert.equal(insecureFlag(SAFE), 'off');
assert.equal(insecureFlag({ config: { insecure_ssl: 0 } }), 'off');
assert.equal(insecureFlag({ config: { insecure_ssl: false } }), 'off');
assert.equal(classify(SAFE, NOW)[0], 'verified');
});
test('every spelling of on is on', () => {
assert.equal(insecureFlag(OPEN), 'on');
assert.equal(insecureFlag({ config: { insecure_ssl: 1 } }), 'on');
assert.equal(insecureFlag({ config: { insecure_ssl: true } }), 'on');
assert.equal(insecureFlag({ config: { insecure_ssl: 'true' } }), 'on');
});
test('an absent flag is unknown rather than either answer', () => {
assert.equal(insecureFlag({ config: { url: 'https://x.example' } }), 'unknown');
assert.equal(insecureFlag({ config: { insecure_ssl: 'maybe' } }), 'unknown');
assert.equal(insecureFlag({ id: 4 }), 'unknown');
const [state, detail] = classify({ id: 4, config: { url: 'https://x.example' } }, NOW);
assert.equal(state, 'flag-unreadable');
assert.match(detail, /rather than assuming/);
});
test('a plaintext hook is handed to the other question', () => {
const [state, detail] = classify(PLAIN, NOW);
assert.equal(state, 'not-applicable');
assert.match(detail, /the scheme is/);
assert.match(repair(state, PLAIN), /behind HTTPS/);
});
test('the finding names the endpoint and dates it as a lower bound', () => {
const [state, detail] = classify(OPEN, NOW);
assert.equal(state, 'verification-off');
assert.match(detail, /https:\/\/hooks\.acme\.io\/github/);
assert.match(detail, /at least 341 day\(s\)/);
assert.equal(unchangedDays(OPEN, NOW), 341);
});
test('a hook with no url is its own state', () => {
const [state] = classify({ id: 5, config: { insecure_ssl: '1' } }, NOW);
assert.equal(state, 'no-url');
});
test('the printed url drops any query string', () => {
const hook = { id: 6, config: { url: 'https://hooks.acme.io/github?token=abc123' } };
assert.equal(endpoint(hook), 'https://hooks.acme.io/github');
assert.equal(schemeOf(hook), 'https');
assert.equal(schemeOf({ config: { url: 'not-a-url' } }), '');
});
test('the repair is a whole config not one field', () => {
const text = repair('verification-off', OPEN);
assert.match(text, /full/);
assert.match(text, /replaced, not merged/);
assert.match(text, /new secret/);
});
test('a hook with no secret gets told to set one', () => {
const hookless = { id: 7, config: { url: 'https://x.example', insecure_ssl: '1' } };
assert.ok(!hasSecret(hookless));
assert.match(repair('verification-off', hookless), /since this hook has none/);
});
test('the summary keeps the plaintext hooks out of the finding count', () => {
assert.deepEqual(summarize([OPEN, SAFE, PLAIN], NOW), {
total: 3, verification_off: 1, verified: 1, plaintext: 1, unreadable: 0,
});
});
test('an unparseable timestamp produces no age', () => {
assert.equal(unchangedDays({ updated_at: 'whenever' }, NOW), null);
assert.equal(unchangedDays({ id: 1 }, NOW), null);
});
FAQ
If the connection is still encrypted, what exactly is the risk?
Impersonation rather than eavesdropping. TLS gives you two things, confidentiality and identity, and this flag throws away the second while keeping the first. GitHub will complete a handshake with any certificate that is presented, so an endpoint that manages to be answered instead of yours - through a stale DNS record somebody else registered, a compromised proxy, an internal resolver pointed at the wrong place - receives your payloads in full. It also receives them correctly signed, which means it can replay them at your real receiver later and they will verify.
Why does a truthy check on insecure_ssl not work?
Because the value is a string and both of its values are non-empty. "0" and "1" are equally truthy in Python, JavaScript, Ruby and everything else you might write the check in, so a naive test flags every hook in the organization including the correctly configured ones. That is worse than not checking, because the first run produces a page of false positives and the tool gets ignored. Parse the value into three states and report the third rather than folding an unreadable field into either answer.
The certificate is genuinely internal. Can we leave it off?
Not as a permanent arrangement, because the exemption is invisible and it outlives the reason for it every time. If GitHub has to reach the endpoint, the endpoint is on the public internet and can have a publicly trusted certificate - that is a free and automated thing now. If it genuinely cannot, put a small gateway in front of it that terminates a real certificate and forwards inside your network, and keep verification on for the hop GitHub makes.
Do we really have to rotate the secret?
The secret was never sent in the payload, so it was not disclosed by this. What was exposed is every payload signed with it, to anything that succeeded in being answered instead of your endpoint during the window - and those payloads replay cleanly. Rotating closes that, and it is cheap. If the flag was on for a week on a staging hook nobody could reach, use your judgement; if it has been on for eleven months on a production hook, rotate.
Why does the script print a whole config instead of one field?
Because a webhook's config is replaced rather than merged, and config.secret comes back masked as asterisks. The obvious repair - read the config, change insecure_ssl, write it back - sends the mask as the secret, so the hook's secret becomes a literal row of asterisks or is dropped, signature verification breaks on the next delivery, and somebody reverts the change believing the flag was load bearing. Send the URL, the content type, the flag and a freshly generated secret together.
Related field notes
- A webhook posting to a plain http:// URL
- A webhook with no secret sends no signature
- Deliveries failing where nobody reads the log
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
- Troubleshooting webhooks — GitHub Docs
- Creating webhooks — GitHub Docs
- Organization webhooks — GitHub REST API
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.