Diagnostic GitHub API
the webhook secret is set and has never been rotated
There is no symptom. The hook works, the signatures verify, the deliveries are green, and the secret that authenticates every event has been sitting in the same configuration file since the integration was built. It has outlasted two contractors, a laptop that was never wiped, and a support ticket where somebody pasted the receiver's environment into a chat window to get help.
This is the opposite state to a hook with no secret. The secret is set, config.secret comes back as ********, and everything is working. The question is how old it is, and the API will not answer it: the value is masked and there is no field anywhere that dates it.
What you can read is updated_at on the hook, which moves whenever the hook's configuration changes. That makes it a one-directional signal, and the direction is the useful one. A hook untouched for four years cannot have had its secret rotated in four years, because rotating it is an edit and an edit moves the timestamp. A hook edited last week proves nothing at all, since the edit may have been the URL. The script argues only in the direction the evidence supports and says which one it is in. Where you can tell it when you last rotated, it does one more thing: if your records claim a rotation the hook's own timestamp predates, the rotation never reached GitHub.
The problem in plain words
Nothing brings this to anybody's attention, which is the whole difficulty. Credentials that expire get rotated because they force the issue; a GitHub webhook secret never expires, is never flagged, and appears in no dashboard. The integration keeps working, so there is no incident to attach the work to and no date at which somebody has to care.
Meanwhile the population of people who have seen the value only ever grows. It is in the receiver's environment, so it is in whatever holds that configuration, and probably in a second place because staging needs one too. It was pasted into a terminal during setup. It may be in a CI variable, in a screenshot in a ticket, in the shell history of a machine that has since been sold. None of that is negligence; it is what happens to a value that lives for six years and is needed by every deployment.
When the question finally does get asked — usually during an audit, or after somebody leaves in a way that makes people think — the answer is not in the API. Somebody looks at the hook, sees secret: ********, and reports that a secret is configured, which was never the question. Then the argument runs on vibes: it was probably rotated at some point, wasn't it, we changed something in there once. The hook's own timestamp settles that in one read, and usually settles it in the unwelcome direction.
There is a second failure hiding under this one, and it is the reason to reconcile against your records rather than just reading the age. Rotation is a two-sided change: the value has to move on GitHub and in the receiver. When it is done under pressure, the receiver gets the new value, the hook does not, signatures start failing, and somebody reverts the receiver to the old value to stop the bleeding. The runbook is then marked done. The secret was never rotated at all, and the record says it was.
Why it happens
The value is unreadable, so the note is honest about using a proxy. config.secret is masked when set and absent when not. There is no created, rotated or expires field for it anywhere in the API. Every claim this script makes about age comes from updated_at, and the output says so rather than presenting an inference as a measurement.
The proxy is conclusive in one direction only. updated_at moves on any change to the hook: the URL, the events array, the active flag, the content type, the secret. So an old timestamp is proof of no rotation, because a rotation would have moved it. A new timestamp is proof of an edit, and nothing more. Treating a recent updated_at as evidence of a recent rotation is the mistake this note is designed to stop, and the script reports that case as inconclusive rather than as healthy.
A hook whose created_at and updated_at agree has never been touched at all. That is the strongest form of the finding: the configuration is exactly as it was created, so the secret is the original one, and its age is the age of the integration. It is also the most common shape, because hooks are set up once by whoever built the thing and then left alone.
Your own records are the other half, and they can be wrong in a way the API can catch. If you claim a rotation on a date and the hook's updated_at is older than that date, the hook has not been modified since before the claimed rotation, so whatever was rotated, it was not this. That is a genuine finding produced entirely from read-only data, and it catches the half-applied rotation that quietly left the old secret in place.
Rotation is not the whole repair for a leaked secret. If the value went somewhere it should not have, rotating removes future access and does nothing about events already captured or about the replay window on anything still in flight. Rotate, then look at what else was in that environment, because a webhook secret rarely leaks alone.
The fix, as a flow
One GET per scope and no secret anywhere in the program. Presence is the only readable fact, the report is built from a config the value has already been stripped out of, and age comes from updated_at, which is a proxy the output names as one. The proxy is conclusive in a single direction: old proves no rotation, recent proves an edit and nothing more. Where the caller supplies a rotation date, one more comparison becomes possible, and it is the one that catches a rotation that only ever reached the receiver.
How to fix it
Split the hooks that have a secret from the ones that do not
GET /repos/{owner}/{repo}/hooks, and add GET /orgs/{org}/hooks if you own the organization, because rotation is an org-wide process problem rather than a repository one. A hook with no secret key in config is a different finding with a different note; this script says so and moves on rather than folding two problems into one number.
Read the only clock there is
updated_at on the hook, compared against today. Compare it with created_at too: if they agree, the hook has never been edited since it was made, and the secret is the one it was born with. Neither field dates the secret directly, and the script never claims they do.
Argue in the direction the evidence supports
Past your rotation interval, the age is conclusive: no rotation has happened in that window, whatever anybody remembers. Inside it, the age proves nothing, because the edit could have been anything. Report those two cases with different words. A check that calls a recently edited hook compliant is worse than no check, because it produces a green tick for a secret nobody has touched in years.
Reconcile the hook against your own rotation record
Give the script the date you believe the secret was last rotated. If the hook's updated_at is earlier than that date, the hook was not modified at or after it, so the rotation did not land on GitHub — the runbook was marked done with only the receiver updated. This is the finding worth going looking for, and it is invisible until you compare the two sides.
Rotate with an overlap window rather than a cutover
Teach the receiver to accept a signature from either the current or the previous secret, deploy that, then change the secret on GitHub, then remove the old value once deliveries have settled. A straight swap drops every event in flight during the gap and, worse, produces a burst of signature failures that looks exactly like an attack. This script cannot perform any of it: changing a hook is a write, and this section does not write.
How to check it worked
Read-only, and cheap enough to run on a schedule so the age is a number somebody sees rather than a question nobody asks.
GITHUB_TOKEN=$RO_TOKEN python3 github_hook_secret_age.py --repo acme/payments --max-age-days 180
# hook 4218871 https://hooks.example.com/github secret=set
# created 2019-04-11, updated 2019-04-11, unedited since creation
# overdue: the hook has not been edited for 2698 days, so its secret has not
# been rotated for at least that long. created_at and updated_at agree, so this
# is the secret the hook was created with.
# repair: rotate with an overlap window: teach the receiver to accept the old
# and the new secret, change it on GitHub, then drop the old value.
# and the one worth going looking for
GITHUB_TOKEN=$RO_TOKEN python3 github_hook_secret_age.py --repo acme/payments --rotated-on 2026-02-14
# rotation-not-applied: the record claims a rotation on 2026-02-14, but the hook
# has not been edited since 2019-04-11. Whatever was rotated, it was not this.
The full code
The secret never enters the program. secret_state answers a question about presence, and redact exists so that anything printed is built from a config the value has already been removed from — there is a test that puts a value where the mask should be and asserts it does not come out the other end. The rest is dates, and the care is all in the wording: the function that decides what the age proves has two branches and they are not two grades of the same answer, they are a finding and an admission.
"""Say how long a webhook secret has gone without being rotated.
Read only. One GET per scope: the repository hook list, and the organization
hook list where the caller owns the organization. Nothing is changed, and no
secret value is ever read, held or printed.
This is the opposite finding to a hook with no secret. Here a secret is set and
working; the question is its age. The API will not answer that: config.secret is
masked and nothing dates it. updated_at on the hook is the only clock available,
and it moves on any edit, which makes it conclusive in exactly one direction. An
old timestamp proves no rotation. A recent one proves an edit and nothing else.
Where the caller can say when they last rotated, one more thing is checkable: a
claimed rotation that predates the hook's own updated_at never reached GitHub.
Environment:
GITHUB_TOKEN a read-only token with access to the repository
"""
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_secret_age")
API = "https://api.github.com"
UA = "github-hook-secret-age/1.0"
# No published guidance exists for how often a webhook secret should be rotated,
# so this is a policy number rather than a fact. The value of the check is that
# somebody chose one.
DEFAULT_MAX_AGE_DAYS = 180
# created_at and updated_at written by the same request can differ by a second.
UNEDITED_TOLERANCE_SECONDS = 60
def secret_state(config):
"""Whether a secret is configured. Never returns the value. Pure.
GitHub masks a set secret and omits the key when there is none, so presence
is the only readable fact and it is the only one this function reports.
"""
if not isinstance(config, dict):
return "unknown"
return "set" if config.get("secret") is not None else "absent"
def redact(config):
"""A copy of a hook config that is safe to print. Pure.
The masked value never needs to leave the response, and building the report
from this rather than from the raw config means a future change to what the
API returns cannot turn into a secret in a log file.
"""
if not isinstance(config, dict):
return {}
safe = {k: v for k, v in config.items() if k != "secret"}
safe["secret"] = secret_state(config)
return safe
def parse_time(text):
"""An ISO 8601 timestamp as an aware datetime, or None. Pure."""
raw = str(text or "").strip()
if not raw:
return None
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
moment = datetime.fromisoformat(raw)
except ValueError:
return None
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def age_days(text, now):
"""Whole days between a timestamp and now, or None. Pure."""
moment = parse_time(text)
if moment is None or now is None:
return None
return int((now - moment).total_seconds() // 86400)
def unedited_since_creation(created_at, updated_at):
"""Whether the hook is exactly as it was created. Pure."""
created, updated = parse_time(created_at), parse_time(updated_at)
if created is None or updated is None:
return False
return abs((updated - created).total_seconds()) <= UNEDITED_TOLERANCE_SECONDS
def evidence_direction(age, threshold):
"""What the age of the last edit actually proves. Pure.
These are not two grades of the same answer. Past the threshold the age is a
lower bound on the secret's age and the finding stands on its own. Inside it,
the edit could have been the URL, and the honest report is that nothing is
known. Calling that second case healthy is the mistake this exists to avoid.
"""
if age is None:
return "unknown"
return "conclusive" if age >= int(threshold) else "inconclusive"
def reconcile(updated_at, claimed, now=None):
"""Compare a claimed rotation date against the hook's own timestamp. Pure."""
claim = parse_time(claimed)
updated = parse_time(updated_at)
if claim is None or updated is None:
return "unknown"
if updated < claim:
return "not-applied"
return "consistent"
def verdict(config, created_at, updated_at, now, threshold=DEFAULT_MAX_AGE_DAYS,
claimed=None):
"""Turn presence, age and any claimed rotation into a finding. Pure."""
state = secret_state(config)
if state != "set":
return ("no-secret",
"this hook has no secret at all, so there is nothing to rotate "
"and every delivery arrives unsigned. That is a different and "
"larger finding than this one.")
age = age_days(updated_at, now)
if age is None:
return ("age-unknown",
"a secret is set, but updated_at could not be read, so nothing "
"about its age can be established from here.")
if claimed:
agreement = reconcile(updated_at, claimed)
if agreement == "not-applied":
return ("rotation-not-applied",
"the record claims a rotation on %s, but the hook has not "
"been edited since %s. Changing a secret is an edit, so "
"whatever was rotated, it was not this hook."
% (str(claimed)[:10], str(updated_at)[:10]))
origin = ("created_at and updated_at agree, so this is the secret the hook "
"was created with." if unedited_since_creation(created_at, updated_at)
else "the hook has been edited since it was created, though not "
"necessarily its secret.")
if evidence_direction(age, threshold) == "conclusive":
return ("overdue",
"the hook has not been edited for %d days, so its secret has "
"not been rotated for at least that long. %s" % (age, origin))
return ("inconclusive",
"the hook was edited %d days ago, which is inside the rotation "
"interval, but an edit is not a rotation: updated_at moves for a "
"URL change too. This is unknown rather than compliant." % age)
def repair(state):
"""The sentence a reader has to act on. Pure."""
if state in ("overdue", "rotation-not-applied"):
return ("rotate with an overlap window: teach the receiver to accept a "
"signature from the old or the new secret, deploy that, change "
"the secret on GitHub, then drop the old value once deliveries "
"have settled. A straight swap loses whatever is in flight.")
if state == "inconclusive":
return ("record rotations somewhere the next person can read, and run "
"this again with that date. The API cannot date a secret, so a "
"written record is the only thing that turns this into an answer.")
if state == "no-secret":
return ("set a secret on the hook and verify X-Hub-Signature-256 in the "
"receiver. Age is not the problem here.")
if state == "age-unknown":
return "read created_at and updated_at on the hook by hand."
return "nothing."
def get(session, path):
"""One GET. Returns (status, json-or-None)."""
r = session.get(API + path, timeout=30)
try:
body = r.json()
except ValueError:
body = None
return r.status_code, body
def hooks_at(session, path, label):
"""Every hook at one scope."""
status, body = get(session, path)
if status != 200 or not isinstance(body, list):
log.info("GET %s returned %d; %s hooks are not readable with this token",
path, status, label)
return []
return body
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--repo", default=os.environ.get("GITHUB_REPO"),
help="owner/name of the repository holding the hook")
ap.add_argument("--org", default=os.environ.get("GITHUB_ORG"),
help="organization to audit as well, if you can read it")
ap.add_argument("--max-age-days", type=int, default=DEFAULT_MAX_AGE_DAYS,
help="your rotation interval. There is no published one")
ap.add_argument("--rotated-on", default=None,
help="the date your records claim the secret was last "
"rotated, as YYYY-MM-DD")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN to a read-only token")
return 2
if not args.repo and not args.org:
log.error("set --repo, --org, or both")
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)
scopes = []
if args.repo:
scopes.append(("/repos/%s/hooks?per_page=100" % args.repo, args.repo))
if args.org:
scopes.append(("/orgs/%s/hooks?per_page=100" % args.org, args.org))
report = []
findings = 0
for path, label in scopes:
for hook in hooks_at(session, path, label):
config = hook.get("config") or {}
safe = redact(config)
log.info("hook %s %s secret=%s", hook.get("id"), safe.get("url"),
safe.get("secret"))
log.info("created %s, updated %s, %s",
str(hook.get("created_at"))[:10], str(hook.get("updated_at"))[:10],
"unedited since creation"
if unedited_since_creation(hook.get("created_at"),
hook.get("updated_at"))
else "edited since creation")
state, detail = verdict(config, hook.get("created_at"),
hook.get("updated_at"), now,
args.max_age_days, args.rotated_on)
log.info("%s: %s", state, detail)
log.info("repair: %s", repair(state))
if state in ("overdue", "rotation-not-applied", "no-secret"):
findings += 1
report.append({
"scope": label,
"hook_id": hook.get("id"),
"config": safe,
"created_at": hook.get("created_at"),
"updated_at": hook.get("updated_at"),
"days_since_edit": age_days(hook.get("updated_at"), now),
"unedited_since_creation": unedited_since_creation(
hook.get("created_at"), hook.get("updated_at")),
"evidence": evidence_direction(
age_days(hook.get("updated_at"), now), args.max_age_days),
"rotation_record": reconcile(hook.get("updated_at"), args.rotated_on),
"state": state,
"detail": detail,
"repair": repair(state),
})
print(json.dumps({"rotation_interval_days": args.max_age_days,
"hooks": report}, indent=2, default=str))
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say how long a webhook secret has gone without being rotated.
*
* Read only. One GET per scope. Nothing is changed, and no secret value is ever
* read, held or printed: presence is the only readable fact and the only one
* this program reports.
*
* updated_at is the only clock the API offers, and it moves on any edit, which
* makes it conclusive in one direction. An old timestamp proves no rotation. A
* recent one proves an edit and nothing else.
*
* Environment:
* GITHUB_TOKEN a read-only token
* GITHUB_REPO owner/name
* GITHUB_ORG optional, audited as well where readable
* GITHUB_ROTATED_ON optional, the date your records claim, as YYYY-MM-DD
*/
const API = 'https://api.github.com';
const UA = 'github-hook-secret-age/1.0';
/** A policy number, not a published one. The value is in having chosen it. */
export const DEFAULT_MAX_AGE_DAYS = 180;
export const UNEDITED_TOLERANCE_SECONDS = 60;
/** Whether a secret is configured. Never returns the value. Pure. */
export function secretState(config) {
if (!config || typeof config !== 'object') return 'unknown';
return config.secret !== null && config.secret !== undefined ? 'set' : 'absent';
}
/** A copy of a hook config that is safe to print. Pure. */
export function redact(config) {
if (!config || typeof config !== 'object') return {};
const safe = {};
for (const [key, value] of Object.entries(config)) {
if (key !== 'secret') safe[key] = value;
}
safe.secret = secretState(config);
return safe;
}
/** An ISO 8601 timestamp as a Date, or null. Pure. */
export function parseTime(text) {
const raw = String(text ?? '').trim();
if (!raw) return null;
const moment = new Date(raw);
return Number.isNaN(moment.getTime()) ? null : moment;
}
/** Whole days between a timestamp and now, or null. Pure. */
export function ageDays(text, now) {
const moment = parseTime(text);
const at = parseTime(now) || (now instanceof Date ? now : null);
if (moment === null || at === null) return null;
return Math.floor((at.getTime() - moment.getTime()) / 86400000);
}
/** Whether the hook is exactly as it was created. Pure. */
export function uneditedSinceCreation(createdAt, updatedAt) {
const created = parseTime(createdAt);
const updated = parseTime(updatedAt);
if (created === null || updated === null) return false;
return Math.abs(updated.getTime() - created.getTime()) <= UNEDITED_TOLERANCE_SECONDS * 1000;
}
/** What the age of the last edit actually proves. Pure. */
export function evidenceDirection(age, threshold) {
if (age === null || age === undefined) return 'unknown';
return age >= Number(threshold) ? 'conclusive' : 'inconclusive';
}
/** Compare a claimed rotation date against the hook's own timestamp. Pure. */
export function reconcile(updatedAt, claimed) {
const claim = parseTime(claimed);
const updated = parseTime(updatedAt);
if (claim === null || updated === null) return 'unknown';
return updated.getTime() < claim.getTime() ? 'not-applied' : 'consistent';
}
/** Turn presence, age and any claimed rotation into a finding. Pure. */
export function verdict(config, createdAt, updatedAt, now,
threshold = DEFAULT_MAX_AGE_DAYS, claimed = null) {
if (secretState(config) !== 'set') {
return ['no-secret',
'this hook has no secret at all, so there is nothing to rotate and every '
+ 'delivery arrives unsigned. That is a different and larger finding than this one.'];
}
const age = ageDays(updatedAt, now);
if (age === null) {
return ['age-unknown',
'a secret is set, but updated_at could not be read, so nothing about its '
+ 'age can be established from here.'];
}
if (claimed && reconcile(updatedAt, claimed) === 'not-applied') {
return ['rotation-not-applied',
`the record claims a rotation on ${String(claimed).slice(0, 10)}, but the `
+ `hook has not been edited since ${String(updatedAt).slice(0, 10)}. Changing `
+ 'a secret is an edit, so whatever was rotated, it was not this hook.'];
}
const origin = uneditedSinceCreation(createdAt, updatedAt)
? 'created_at and updated_at agree, so this is the secret the hook was created with.'
: 'the hook has been edited since it was created, though not necessarily its secret.';
if (evidenceDirection(age, threshold) === 'conclusive') {
return ['overdue',
`the hook has not been edited for ${age} days, so its secret has not been `
+ `rotated for at least that long. ${origin}`];
}
return ['inconclusive',
`the hook was edited ${age} days ago, which is inside the rotation interval, `
+ 'but an edit is not a rotation: updated_at moves for a URL change too. '
+ 'This is unknown rather than compliant.'];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state) {
if (['overdue', 'rotation-not-applied'].includes(state)) {
return 'rotate with an overlap window: teach the receiver to accept a '
+ 'signature from the old or the new secret, deploy that, change the secret '
+ 'on GitHub, then drop the old value once deliveries have settled. A '
+ 'straight swap loses whatever is in flight.';
}
if (state === 'inconclusive') {
return 'record rotations somewhere the next person can read, and run this '
+ 'again with that date. The API cannot date a secret, so a written record '
+ 'is the only thing that turns this into an answer.';
}
if (state === 'no-secret') {
return 'set a secret on the hook and verify X-Hub-Signature-256 in the '
+ 'receiver. Age is not the problem here.';
}
if (state === 'age-unknown') return 'read created_at and updated_at on the hook by hand.';
return 'nothing.';
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPO;
const org = process.env.GITHUB_ORG;
if (!token || (!repo && !org)) {
console.error('set GITHUB_TOKEN and at least one of GITHUB_REPO, GITHUB_ORG');
process.exitCode = 2;
return;
}
const threshold = Number(process.env.GITHUB_MAX_AGE_DAYS || DEFAULT_MAX_AGE_DAYS);
const claimed = process.env.GITHUB_ROTATED_ON || null;
const now = new Date();
const scopes = [];
if (repo) scopes.push([`/repos/${repo}/hooks?per_page=100`, repo]);
if (org) scopes.push([`/orgs/${org}/hooks?per_page=100`, org]);
const report = [];
let findings = 0;
for (const [path, label] of scopes) {
const res = await fetch(API + path, { headers: headers(token) });
if (res.status !== 200) {
console.log(`GET ${path} returned ${res.status}; ${label} hooks are not readable`);
continue;
}
const hooks = await res.json();
for (const hook of Array.isArray(hooks) ? hooks : []) {
const config = hook.config || {};
const safe = redact(config);
console.log(`hook ${hook.id} ${safe.url} secret=${safe.secret}`);
const [state, detail] = verdict(config, hook.created_at, hook.updated_at,
now, threshold, claimed);
console.log(`${state}: ${detail}`);
console.log(`repair: ${repair(state)}`);
if (['overdue', 'rotation-not-applied', 'no-secret'].includes(state)) findings += 1;
report.push({
scope: label,
hook_id: hook.id,
config: safe,
created_at: hook.created_at,
updated_at: hook.updated_at,
days_since_edit: ageDays(hook.updated_at, now),
unedited_since_creation: uneditedSinceCreation(hook.created_at, hook.updated_at),
evidence: evidenceDirection(ageDays(hook.updated_at, now), threshold),
rotation_record: reconcile(hook.updated_at, claimed),
state,
});
}
}
console.log(JSON.stringify({ rotation_interval_days: threshold, hooks: report }, null, 2));
process.exitCode = findings ? 1 : 0;
}
// Guarded so importing this file from the test runner does not start a run and
// fail 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 things are worth testing here and the rest is arithmetic. The first is that nothing resembling a secret survives a round trip through the report: a fixture is given a value where the mask normally sits, and the test asserts the value is not in the printed output. The second is the asymmetry of the evidence — an old hook is a finding, a recently edited one is explicitly not a pass — because a check that quietly grades a recent edit as compliant would be worse than not running it.
import json
from datetime import datetime, timezone
from github_hook_secret_age import (
age_days, evidence_direction, parse_time, reconcile, redact, repair,
secret_state, unedited_since_creation, verdict,
)
NOW = datetime(2026, 8, 31, tzinfo=timezone.utc)
OLD = "2019-04-11T22:14:38Z"
RECENT = "2026-08-01T09:00:00Z"
MASKED = {"url": "https://hooks.example.com/github", "secret": "********",
"content_type": "json"}
NO_SECRET = {"url": "https://hooks.example.com/github", "content_type": "json"}
def test_presence_is_the_only_fact_read_about_a_secret():
assert secret_state(MASKED) == "set"
assert secret_state(NO_SECRET) == "absent"
assert secret_state(None) == "unknown"
def test_no_secret_value_survives_into_the_report():
leaked = {"url": "https://hooks.example.com", "secret": "not-a-real-value"}
printed = json.dumps(redact(leaked))
assert "not-a-real-value" not in printed
assert redact(leaked)["secret"] == "set"
assert redact(MASKED)["secret"] == "set"
assert "********" not in json.dumps(redact(MASKED))
def test_timestamps_are_parsed_and_aged_in_whole_days():
assert parse_time(OLD).year == 2019
assert parse_time("2019-04-11T22:14:38+00:00") == parse_time(OLD)
assert parse_time("nonsense") is None
assert parse_time(None) is None
assert age_days(RECENT, NOW) == 29
assert age_days(None, NOW) is None
def test_a_hook_never_edited_since_creation_is_recognised():
assert unedited_since_creation(OLD, OLD)
assert unedited_since_creation("2019-04-11T22:14:38Z", "2019-04-11T22:14:59Z")
assert not unedited_since_creation(OLD, RECENT)
assert not unedited_since_creation(None, RECENT)
def test_the_evidence_only_points_one_way():
assert evidence_direction(2698, 180) == "conclusive"
assert evidence_direction(29, 180) == "inconclusive"
assert evidence_direction(180, 180) == "conclusive"
assert evidence_direction(None, 180) == "unknown"
def test_an_ancient_hook_is_the_finding():
state, detail = verdict(MASKED, OLD, OLD, NOW, 180)
assert state == "overdue"
assert "2698 days" in detail
assert "the secret the hook was created with" in detail
def test_a_recent_edit_is_not_graded_as_compliant():
state, detail = verdict(MASKED, OLD, RECENT, NOW, 180)
assert state == "inconclusive"
assert "an edit is not a rotation" in detail
assert "unknown rather than compliant" in detail
def test_an_absent_secret_is_handed_to_the_other_note():
state, detail = verdict(NO_SECRET, OLD, OLD, NOW, 180)
assert state == "no-secret"
assert "nothing to rotate" in detail
assert "Age is not the problem" in repair("no-secret")
def test_a_claimed_rotation_the_hook_predates_is_a_finding():
assert reconcile(OLD, "2026-02-14") == "not-applied"
assert reconcile(RECENT, "2026-02-14") == "consistent"
assert reconcile(RECENT, None) == "unknown"
state, detail = verdict(MASKED, OLD, OLD, NOW, 180, claimed="2026-02-14")
assert state == "rotation-not-applied"
assert "2026-02-14" in detail
assert "it was not this hook" in detail
def test_a_claim_the_hook_supports_does_not_override_the_age():
state, _ = verdict(MASKED, OLD, RECENT, NOW, 180, claimed="2026-02-14")
assert state == "inconclusive"
def test_an_unreadable_timestamp_is_admitted_rather_than_guessed():
state, detail = verdict(MASKED, OLD, "not a date", NOW, 180)
assert state == "age-unknown"
assert "nothing about its age" in detail
def test_the_repair_never_suggests_a_straight_swap():
assert "overlap window" in repair("overdue")
assert "overlap window" in repair("rotation-not-applied")
assert "written record" in repair("inconclusive")
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ageDays, evidenceDirection, parseTime, reconcile, redact, repair, secretState,
uneditedSinceCreation, verdict,
} from './github-hook-secret-age.mjs';
const NOW = new Date('2026-08-31T00:00:00Z');
const OLD = '2019-04-11T22:14:38Z';
const RECENT = '2026-08-01T09:00:00Z';
const MASKED = { url: 'https://hooks.example.com/github', secret: '********', content_type: 'json' };
const NO_SECRET = { url: 'https://hooks.example.com/github', content_type: 'json' };
test('presence is the only fact read about a secret', () => {
assert.equal(secretState(MASKED), 'set');
assert.equal(secretState(NO_SECRET), 'absent');
assert.equal(secretState(null), 'unknown');
});
test('no secret value survives into the report', () => {
const leaked = { url: 'https://hooks.example.com', secret: 'not-a-real-value' };
const printed = JSON.stringify(redact(leaked));
assert.ok(!printed.includes('not-a-real-value'));
assert.equal(redact(leaked).secret, 'set');
assert.equal(redact(MASKED).secret, 'set');
assert.ok(!JSON.stringify(redact(MASKED)).includes('********'));
});
test('timestamps are parsed and aged in whole days', () => {
assert.equal(parseTime(OLD).getUTCFullYear(), 2019);
assert.equal(parseTime('2019-04-11T22:14:38+00:00').getTime(), parseTime(OLD).getTime());
assert.equal(parseTime('nonsense'), null);
assert.equal(parseTime(null), null);
assert.equal(ageDays(RECENT, NOW), 29);
assert.equal(ageDays(null, NOW), null);
});
test('a hook never edited since creation is recognised', () => {
assert.ok(uneditedSinceCreation(OLD, OLD));
assert.ok(uneditedSinceCreation('2019-04-11T22:14:38Z', '2019-04-11T22:14:59Z'));
assert.ok(!uneditedSinceCreation(OLD, RECENT));
assert.ok(!uneditedSinceCreation(null, RECENT));
});
test('the evidence only points one way', () => {
assert.equal(evidenceDirection(2698, 180), 'conclusive');
assert.equal(evidenceDirection(29, 180), 'inconclusive');
assert.equal(evidenceDirection(180, 180), 'conclusive');
assert.equal(evidenceDirection(null, 180), 'unknown');
});
test('an ancient hook is the finding', () => {
const [state, detail] = verdict(MASKED, OLD, OLD, NOW, 180);
assert.equal(state, 'overdue');
assert.match(detail, /2698 days/);
assert.match(detail, /the secret the hook was created with/);
});
test('a recent edit is not graded as compliant', () => {
const [state, detail] = verdict(MASKED, OLD, RECENT, NOW, 180);
assert.equal(state, 'inconclusive');
assert.match(detail, /an edit is not a rotation/);
assert.match(detail, /unknown rather than compliant/);
});
test('an absent secret is handed to the other note', () => {
const [state, detail] = verdict(NO_SECRET, OLD, OLD, NOW, 180);
assert.equal(state, 'no-secret');
assert.match(detail, /nothing to rotate/);
assert.match(repair('no-secret'), /Age is not the problem/);
});
test('a claimed rotation the hook predates is a finding', () => {
assert.equal(reconcile(OLD, '2026-02-14'), 'not-applied');
assert.equal(reconcile(RECENT, '2026-02-14'), 'consistent');
assert.equal(reconcile(RECENT, null), 'unknown');
const [state, detail] = verdict(MASKED, OLD, OLD, NOW, 180, '2026-02-14');
assert.equal(state, 'rotation-not-applied');
assert.match(detail, /2026-02-14/);
assert.match(detail, /it was not this hook/);
});
test('a claim the hook supports does not override the age', () => {
assert.equal(verdict(MASKED, OLD, RECENT, NOW, 180, '2026-02-14')[0], 'inconclusive');
});
test('an unreadable timestamp is admitted rather than guessed', () => {
const [state, detail] = verdict(MASKED, OLD, 'not a date', NOW, 180);
assert.equal(state, 'age-unknown');
assert.match(detail, /nothing about its age/);
});
test('the repair never suggests a straight swap', () => {
assert.match(repair('overdue'), /overlap window/);
assert.match(repair('rotation-not-applied'), /overlap window/);
assert.match(repair('inconclusive'), /written record/);
});
FAQ
Is this the same as the note about a hook with no secret?
No, it is the opposite state. That note's finding is absence: <code>config</code> has no <code>secret</code> key, no signature header is sent, and any receiver checking a signature only when one is present is checking nothing. This note starts where that one ends. The secret is set, signatures are being sent and verified, and the question is how long the same value has been doing that job. The script recognises an absent secret and hands it straight to the other note rather than reporting it as very overdue, because the repairs are not the same work.
Why can the API not tell me how old the secret is?
Because it will not tell you anything about the secret except that there is one. <code>config.secret</code> comes back as a row of asterisks when set and is missing entirely when not, and no field records when it was written. That is deliberate and correct — a read-only token should not be able to exfiltrate the value that authenticates your events — but it means age has to come from a proxy. <code>updated_at</code> is the only one available, and the script says out loud that it is using one.
Our hook was updated last month. Are we compliant?
Unknown, which is a different answer from yes, and the script reports it as unknown on purpose. <code>updated_at</code> moves for any change to the hook: the URL, the events array, the active flag, the content type. A rotation would move it, and so would fixing a typo in the endpoint. The timestamp is only conclusive when it is old, because then it rules a rotation out. When it is recent it rules nothing in, and a check that grades that as a pass is producing a green tick with no evidence behind it.
How do we rotate without losing events?
Use an overlap window rather than a cutover. Teach the receiver to accept a signature computed with either the current or the previous secret and deploy that first. Then change the secret on GitHub. Then, once deliveries have settled and you can see the new value working, remove the old one from the receiver. A straight swap drops everything in flight during the gap and generates a burst of signature failures that reads exactly like someone attacking your endpoint, which is a bad thing to schedule for a Friday.
How old is too old?
GitHub publishes no guidance, so this is a policy number you choose rather than a fact you look up: ninety days, a hundred and eighty, a year. The number matters far less than having one, because the failure mode here is not a secret that is slightly too old, it is a secret that has never been changed since 2019 and is on a laptop somebody sold. If the value has actually leaked, rotation is only the first step: it closes future access and does nothing about events already captured, so look at what else lived in that environment.
Related field notes
- A webhook with no secret sends no signature
- Certificate verification switched off on the hook
- A token that expires in a few days
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.
- Validating webhook deliveries — GitHub Docs
- Repository webhooks — GitHub REST API
- Best practices for using webhooks — GitHub Docs
- Creating 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.