Diagnostic GitHub API
A 403 that means the feature is disabled, not the permission
The security dashboard job gets 403 from /code-scanning/alerts. That is the status a missing grant produces, so somebody adds the security-events permission. Same 403. They swap the fine-grained token for an App installation with every security permission ticked. Same 403. Two weeks later a repository admin opens the settings page and code scanning has never been turned on for that repository, which is a checkbox, not a grant, and no credential in the world was going to open it.
Some endpoints require the underlying feature to be switched on for that repository as well as a permission on the caller. When the feature is off, the endpoint refuses everybody, including a token holding exactly the permission it names.
Read the switch instead of guessing at the grant. GET /repos/{owner}/{repo} carries security_and_analysis, with a status of enabled or disabled for advanced_security, secret_scanning, secret_scanning_push_protection and dependabot_security_updates, alongside the plain toggles has_issues, has_wiki, has_projects and has_discussions. One read settles the whole matrix before any alert endpoint is called.
The trap worth naming: one off switch produces three different status codes. Code scanning refuses with 403, secret scanning with 404, and an issues endpoint on a repository with issues disabled answers 410 Gone. Only the first looks like permissions, and all three mean the same thing.
The problem in plain words
The reason this eats a fortnight is that the 403 arrives without the header that normally ends these arguments. A grant-shaped 403 from a fine-grained token or an App carries x-accepted-github-permissions naming what the endpoint wanted; a feature-shaped one has nothing to name, because the endpoint is not asking for anything. Nobody notices an absent header. They notice the status code, and the status code says permissions.
So the escalation runs upward through credential types. Add the permission. Regenerate the token. Try a classic PAT. Install a GitHub App with everything ticked, which is the point at which somebody has quietly created an over-wide credential in production to solve a problem that was never about credentials. Each step is plausible, each step fails identically, and the failure never varies enough to suggest the search is in the wrong place.
The second thing that hides it is the inconsistency of the codes. A team that has learned the 403 for code scanning will meet the 404 for secret scanning and file it as a different bug — probably as a missing repository, or as the alerts endpoint not existing on that plan. And an issues endpoint returning 410 Gone reads like a deprecation, which is exactly what it is not: it is GitHub saying issues are switched off for this repository, in the status code reserved for something that used to be here.
Then there is the plan. Advanced Security features on private repositories depend on what the organization is paying for, so a flag can be disabled in a way no repository admin can change. That distinction does not show up in the refusal either.
Why it happens
Two switches in series, and only one of them is on your side of the call. The permission is a property of the caller. The feature is a property of the repository. An endpoint gated on both refuses when either is missing, and the two refusals are not distinguishable by status code — which is why the diagnosis has to come from a reading rather than from the error.
The security block is the single readable source, and it is not always readable. security_and_analysis is returned on the repository object only to a caller with admin on that repository. A collaborator with read gets the same repository object with the block absent entirely. That gives a third state that must not be collapsed into the other two: unreported is not disabled. It also gives a neat handoff, because the reason it is unreported is the caller's own role, which is the note next door.
One switch, three status codes, and only the first looks like permissions. Code scanning answers 403 when Advanced Security is not enabled. Secret scanning answers 404, which reads as a missing resource. Issues endpoints on a repository with has_issues: false answer 410 Gone. Wikis and discussions do not have general REST alert endpoints at all, so the flag is the only signal you get. Knowing the mapping is the difference between one diagnosis and three separate investigations.
Some flags describe the feature exactly and some are the closest thing available. secret_scanning governs the secret-scanning endpoints directly. advanced_security governs code scanning on private repositories directly. dependabot_security_updates is the automatic-pull-request setting rather than a flag for Dependabot alerts as such, so a disabled there is strong evidence about the alerts endpoint and not proof. The script marks which mappings are exact and which are proxies rather than presenting a uniform confidence it does not have.
Plan-dependence is a repair a repository admin cannot perform. On a private or internal repository, the Advanced Security features are only available where the plan includes them, so advanced_security: disabled can mean nobody has ticked it or can mean nobody is able to. On public repositories the same features are generally available, which is why the identical script run against a public fork of a private repository sometimes answers differently and confuses everybody.
Everything here is established by reading, and the probe is a read too. The repository object is one GET. The optional confirmation step is a GET per endpoint, which is the same call the failing job makes, so it costs nothing that job was not already spending and changes nothing. The script records the status it got and compares it against the status a disabled feature is documented to produce, so a mismatch is reported as a mismatch rather than forced into the story.
The fix, as a flow
One repository read answers the question, and it answers it for every feature at once. security_and_analysis carries a status per security feature and the has_ flags carry the rest, so the script can say which endpoints this repository will serve before any of them is called. The third state matters as much as the other two: that block is only returned to a caller with admin on the repository, so an absent flag is unreported rather than off, and the reason it is absent is the reader's own role. The optional probe is a GET per endpoint, and the status it records is compared against the one a disabled feature is documented to produce.
How to fix it
Read every flag in one request
The script fetches GET /repos/{owner}/{repo} once and prints the whole matrix: the security_and_analysis statuses and the has_issues, has_wiki, has_projects, has_discussions toggles beside them. That is the entire question answered for every feature at once, for one request against the core quota.
Name the endpoint that refused you
Pass --endpoint /code-scanning/alerts and the script maps it to the feature that gates it, reports that feature's state, and says which status code a disabled feature produces on that endpoint. A full URL works too — the owner and repository are stripped, so you can paste the line out of your logs.
Separate the switch from the grant
If you have the refusal, give the script --status 403 and --accepted-permissions security_events=read if the response carried that header. A named permission with the feature enabled is a permission problem and belongs to a different note; a disabled feature with no header is this one. A status that does not match what a disabled feature produces is reported as a mismatch rather than quietly assimilated.
Confirm with a read if you want to, and only a read
--probe sends one GET per mapped endpoint and records the status. It is the same call your job already makes and it changes nothing; the read cost is printed before any of it is spent. The probe exists to corroborate the flag, not to establish the finding, which is why the script is just as useful without it.
Send the repair to the right desk
A disabled feature is a repository setting, so the repair goes to somebody with admin on the repository or to the organization's security settings, not to whoever manages tokens. Where the flag is disabled on a private repository and the feature is Advanced Security, the script says plainly that the plan may be the constraint. Where the block is absent it says the reading is unavailable at your role rather than pretending the feature is off.
How to check it worked
Once the feature is enabled the same read reports it, and the endpoint that spent a fortnight looking like a permissions problem starts answering.
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_feature_flags.py octo-org/payments \
--endpoint /code-scanning/alerts --status 403
# read cost: 1 request(s) against the core hourly quota
# octo-org/payments: private=True
# security_and_analysis: advanced_security=disabled secret_scanning=disabled
# secret_scanning_push_protection=disabled dependabot_security_updates=enabled
# toggles: has_issues=True has_wiki=False has_projects=True has_discussions=False
# /code-scanning/alerts -> advanced_security (exact), 403 when disabled
# feature-disabled: advanced_security is disabled on this repository, and the
# 403 you recorded is exactly what a disabled feature produces here. No
# permission opens it.
# plan-note: this is a private repository, so Advanced Security availability
# depends on the plan as well as on the checkbox.
# repair: enable code scanning on octo-org/payments in the repository's
# security settings, or at organization level, then grant the token
# security_events read. Both, in that order.
The full code
The mapping table is the substance of this one, and it carries more than a feature name: each row says which flag gates the endpoint, whether that mapping is exact or a proxy, and which status code a disabled feature produces there. Everything else reads one repository object and looks things up. The three states are kept strictly apart — enabled, disabled, and unreported for the block a non-admin never receives — because collapsing the third into the second would turn a limit of the reader's own access into a confident and wrong claim about the repository.
"""Say whether an endpoint refused you because the feature is switched off.
Read only. GET requests and nothing else. Nothing here is established by
attempting anything: the repository object carries every feature flag, and the
optional probe is the same GET the failing job already makes.
The point of the note: some endpoints are gated on a repository feature as well
as on a permission. When the feature is off they refuse everybody, including a
caller holding exactly the permission the endpoint names, and one off switch
produces three different status codes depending on the endpoint family.
What this can and cannot see: security_and_analysis is only returned to a
caller with admin on the repository. An absent block is therefore unreported,
not disabled, and the script keeps those apart. Some flags gate their endpoint
exactly and one is the closest available proxy; the table says which.
Environment:
GITHUB_TOKEN a token with read access to the repository
"""
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_feature_flags")
API = "https://api.github.com"
UA = "github-feature-flags/1.0"
# Read out of security_and_analysis, which is a map of {name: {status: ...}}.
SECURITY_FEATURES = (
"advanced_security",
"secret_scanning",
"secret_scanning_push_protection",
"secret_scanning_non_provider_patterns",
"dependabot_security_updates",
)
# Plain booleans on the repository object itself. These come back to any caller
# who can read the repository, unlike the security block.
TOGGLES = ("has_issues", "has_wiki", "has_projects", "has_discussions",
"has_pages", "has_downloads")
# The table this note exists for. Each row: which flag gates the endpoint, where
# that flag is read from, the status a disabled feature produces there, and
# whether the mapping is exact or the closest available proxy.
#
# The three status codes are the whole trap. Only the 403 looks like a
# permissions problem, and all three mean the feature is off.
ENDPOINT_FEATURES = {
"/code-scanning/alerts": ("advanced_security", "security", 403, "exact"),
"/code-scanning/analyses": ("advanced_security", "security", 403, "exact"),
"/secret-scanning/alerts": ("secret_scanning", "security", 404, "exact"),
"/dependabot/alerts": ("dependabot_security_updates", "security", 403, "proxy"),
"/issues": ("has_issues", "toggle", 410, "exact"),
"/issues/comments": ("has_issues", "toggle", 410, "exact"),
"/milestones": ("has_issues", "toggle", 410, "exact"),
}
# Why the proxy rows are not presented as certainties.
PROXY_NOTE = ("this flag is the closest one the repository object publishes for "
"that endpoint rather than a switch for it exactly, so a disabled "
"reading here is strong evidence and not proof.")
# Advanced Security on a private or internal repository depends on the plan as
# well as on the checkbox, which is a repair a repository admin cannot make.
PLAN_DEPENDENT = ("advanced_security", "secret_scanning",
"secret_scanning_push_protection")
def read_cost(probes=0):
"""Requests this run will spend against the core quota. Pure."""
return 1 + max(0, int(probes or 0))
def normalise_endpoint(path):
"""Reduce a logged URL to a key in the table. Pure.
Accepts a full URL, a /repos/{owner}/{repo}/... path or the bare suffix, so
a line can be pasted straight out of a log without editing.
"""
text = str(path or "").strip()
for prefix in ("https://api.github.com", "http://api.github.com"):
if text.startswith(prefix):
text = text[len(prefix):]
text = text.split("?")[0].rstrip("/")
if not text:
return ""
if not text.startswith("/"):
text = "/" + text
if text.startswith("/repos/"):
parts = text.split("/")
# /repos/{owner}/{repo}/rest -> /rest
if len(parts) > 4:
text = "/" + "/".join(parts[4:])
else:
text = "/"
return text
def feature_for(path):
"""The table row for an endpoint, or None. Pure."""
key = normalise_endpoint(path)
row = ENDPOINT_FEATURES.get(key)
if row is None:
return None
feature, source, status, confidence = row
return {"endpoint": key, "feature": feature, "source": source,
"status_when_disabled": status, "confidence": confidence}
def security_block(repo):
"""The security_and_analysis map, or None when it was not returned. Pure."""
block = (repo or {}).get("security_and_analysis")
return block if isinstance(block, dict) else None
def flag_state(repo, feature, source):
"""enabled, disabled or unreported for one feature. Pure.
unreported is a real third answer. The security block is only sent to a
caller with admin on the repository, so its absence describes the reader
rather than the repository, and calling that "disabled" would be a
confident claim built on a missing grant.
"""
if source == "toggle":
value = (repo or {}).get(feature)
if value is True:
return "enabled"
if value is False:
return "disabled"
return "unreported"
block = security_block(repo)
if block is None:
return "unreported"
entry = block.get(feature)
if not isinstance(entry, dict):
return "unreported"
status = str(entry.get("status") or "").strip().lower()
if status in ("enabled", "disabled"):
return status
return "unreported"
def matrix(repo):
"""Every endpoint in the table with the state of the flag gating it. Pure."""
rows = []
for key in sorted(ENDPOINT_FEATURES):
row = feature_for(key)
row["state"] = flag_state(repo, row["feature"], row["source"])
row["will_serve"] = {"enabled": True, "disabled": False}.get(row["state"])
rows.append(row)
return rows
def plan_may_be_the_constraint(repo, feature):
"""Is this a repair a repository admin might not be able to make. Pure."""
if feature not in PLAN_DEPENDENT:
return False
visibility = str((repo or {}).get("visibility") or "").strip().lower()
return bool((repo or {}).get("private")) or visibility in ("private", "internal")
def status_matches(row, observed):
"""Does the recorded status match what a disabled feature produces. Pure.
Returns True, False, or None when nothing was recorded. A mismatch is worth
saying out loud: it means the refusal probably has a different cause, and
forcing it into the story here would be the same mistake in a new direction.
"""
if observed in (None, ""):
return None
try:
return int(observed) == int(row["status_when_disabled"])
except (TypeError, ValueError):
return None
def classify(repo, row, observed_status=None, accepted_permissions=None):
"""Attribute one refusal to the switch or to the grant. Pure.
Returns (state, detail).
"""
if row is None:
return ("endpoint-unknown",
"that endpoint is not one of the feature-gated ones in this "
"table, so a refusal from it is not this note. Read the whole "
"flag matrix above and check the permission headers instead.")
state_of_flag = row["state"] if "state" in row else flag_state(
repo, row["feature"], row["source"])
named = str(accepted_permissions or "").strip()
match = status_matches(row, observed_status)
if state_of_flag == "unreported":
return ("feature-unreported",
"%s could not be read. The security_and_analysis block is only "
"returned to a caller with admin on the repository, so this "
"says something about your own role rather than about the "
"feature." % row["feature"])
if state_of_flag == "disabled":
if match is False:
return ("status-mismatch",
"%s is disabled, but a disabled feature answers %s on this "
"endpoint and you recorded %s. Fix the feature and expect "
"the other failure to survive it."
% (row["feature"], row["status_when_disabled"], observed_status))
return ("feature-disabled",
"%s is disabled on this repository, and %s is what a disabled "
"feature produces here. No permission opens it."
% (row["feature"], row["status_when_disabled"]))
if named:
return ("permission-named",
"%s is enabled and the response named '%s' in "
"x-accepted-github-permissions, so this is a grant that is "
"missing rather than a feature that is off."
% (row["feature"], named))
return ("feature-enabled",
"%s is enabled, so the feature is not what refused you. Look at the "
"credential next: a fine-grained token names no permission on its "
"own refusal, and an App names one in a header." % row["feature"])
def repair(state, row, repo=None):
"""The sentence a reader has to act on. Pure."""
feature = (row or {}).get("feature", "the feature")
if state == "feature-disabled":
text = ("enable %s on this repository in its security settings, or at "
"organization level for every repository, then grant the "
"caller the matching read permission. Both, in that order."
% feature)
if plan_may_be_the_constraint(repo or {}, feature):
text += (" This is a private or internal repository, so "
"availability depends on the plan as well as on the "
"checkbox, and that part is not a repository setting.")
if (row or {}).get("confidence") == "proxy":
text += " Note that " + PROXY_NOTE
return text
if state == "feature-unreported":
return ("read the repository with an account that has admin on it, or "
"ask an admin what the setting says. Do not record this as "
"disabled: an absent block is a limit on your reading.")
if state == "permission-named":
return ("grant the named permission. The feature is on, so this is the "
"ordinary permissions path and not this note.")
if state == "status-mismatch":
return ("enable the feature anyway, then diagnose the recorded status "
"separately. Two causes were in play and only one of them is "
"addressed here.")
if state == "feature-enabled":
return ("look at the credential. Nothing about the repository's "
"features explains this refusal.")
return ("name the endpoint that refused you with --endpoint so the flag can "
"be mapped to it.")
def get(session, path):
"""One GET. Returns the response object."""
r = session.get(API + path, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
"or revoked")
return r
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("repo", help="owner/name of the repository")
ap.add_argument("--endpoint", default="",
help="the endpoint that refused you, e.g. "
"/code-scanning/alerts. A full URL is accepted.")
ap.add_argument("--status", default="",
help="the status code you recorded from it")
ap.add_argument("--accepted-permissions", default="",
help="x-accepted-github-permissions off that response, if "
"it carried one")
ap.add_argument("--probe", action="store_true",
help="also GET each mapped endpoint to record its status. "
"Reads only, and the same call your job already makes.")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (a read-only token is enough)")
return 2
if "/" not in args.repo:
log.error("repo should be owner/name")
return 2
probes = len(ENDPOINT_FEATURES) if args.probe else 0
log.info("read cost: %d request(s) against the core hourly quota",
read_cost(probes))
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub rejects requests with no User-Agent outright.
"User-Agent": UA,
})
response = get(session, "/repos/" + args.repo)
if response.status_code != 200:
log.error("%s: HTTP %s reading the repository", args.repo,
response.status_code)
return 2
repo = response.json()
log.info("%s: private=%s", args.repo, repo.get("private"))
block = security_block(repo)
if block is None:
log.info("security_and_analysis: not returned. That block is only sent "
"to a caller with admin on the repository.")
else:
log.info("security_and_analysis: %s", " ".join(
"%s=%s" % (f, flag_state(repo, f, "security"))
for f in SECURITY_FEATURES))
log.info("toggles: %s", " ".join(
"%s=%s" % (t, repo.get(t)) for t in TOGGLES))
rows = matrix(repo)
probed = {}
if args.probe:
for row in rows:
r = get(session, "/repos/%s%s" % (args.repo, row["endpoint"]))
probed[row["endpoint"]] = r.status_code
log.info("probe %s -> HTTP %s (flag %s)", row["endpoint"],
r.status_code, row["state"])
target = feature_for(args.endpoint) if args.endpoint else None
if target:
target["state"] = flag_state(repo, target["feature"], target["source"])
log.info("%s -> %s (%s), %s when disabled", target["endpoint"],
target["feature"], target["confidence"],
target["status_when_disabled"])
state, detail = classify(repo, target, args.status,
args.accepted_permissions)
log.info("%s: %s", state, detail)
if target and plan_may_be_the_constraint(repo, target["feature"]):
log.info("plan-note: this is a private or internal repository, so "
"availability depends on the plan as well as on the checkbox.")
log.info("repair: %s", repair(state, target, repo))
print(json.dumps({
"repository": args.repo,
"private": repo.get("private"),
"visibility": repo.get("visibility"),
"security_block_returned": block is not None,
"security_and_analysis": {
f: flag_state(repo, f, "security") for f in SECURITY_FEATURES},
"toggles": {t: repo.get(t) for t in TOGGLES},
"matrix": rows,
"probed": probed,
"endpoint": target,
"state": state,
"detail": detail,
"repair": repair(state, target, repo),
}, indent=2, default=str))
return 1 if state in ("feature-disabled", "status-mismatch") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say whether an endpoint refused you because the feature is switched off.
*
* Read only. GET requests and nothing else. The repository object carries every
* feature flag, so nothing here is established by attempting anything.
*
* Some endpoints are gated on a repository feature as well as on a permission.
* When the feature is off they refuse everybody, and one off switch produces
* three different status codes depending on the endpoint family: 403 for code
* scanning, 404 for secret scanning, 410 for issues.
*
* Environment:
* GITHUB_TOKEN a token with read access to the repository
* GITHUB_REPO owner/name
* GITHUB_ENDPOINT the endpoint that refused you
* GITHUB_STATUS the status code you recorded from it
* GITHUB_ACCEPTED x-accepted-github-permissions, if the response had one
*/
const API = 'https://api.github.com';
const UA = 'github-feature-flags/1.0';
/** Read out of security_and_analysis, a map of {name: {status}}. */
export const SECURITY_FEATURES = [
'advanced_security',
'secret_scanning',
'secret_scanning_push_protection',
'secret_scanning_non_provider_patterns',
'dependabot_security_updates',
];
/** Plain booleans on the repository object, visible to any reader. */
export const TOGGLES = ['has_issues', 'has_wiki', 'has_projects',
'has_discussions', 'has_pages', 'has_downloads'];
/** Flag, where it is read, status when disabled, and how exact the mapping is. */
export const ENDPOINT_FEATURES = {
'/code-scanning/alerts': ['advanced_security', 'security', 403, 'exact'],
'/code-scanning/analyses': ['advanced_security', 'security', 403, 'exact'],
'/secret-scanning/alerts': ['secret_scanning', 'security', 404, 'exact'],
'/dependabot/alerts': ['dependabot_security_updates', 'security', 403, 'proxy'],
'/issues': ['has_issues', 'toggle', 410, 'exact'],
'/issues/comments': ['has_issues', 'toggle', 410, 'exact'],
'/milestones': ['has_issues', 'toggle', 410, 'exact'],
};
/** Why the proxy rows are not presented as certainties. */
export const PROXY_NOTE = 'this flag is the closest one the repository object '
+ 'publishes for that endpoint rather than a switch for it exactly, so a '
+ 'disabled reading here is strong evidence and not proof.';
/** Advanced Security on a private repository depends on the plan too. */
export const PLAN_DEPENDENT = ['advanced_security', 'secret_scanning',
'secret_scanning_push_protection'];
/** Requests this run will spend against the core quota. Pure. */
export function readCost(probes = 0) {
return 1 + Math.max(0, Math.trunc(Number(probes) || 0));
}
/** Reduce a logged URL to a key in the table. Pure. */
export function normaliseEndpoint(path) {
let text = String(path ?? '').trim();
for (const prefix of ['https://api.github.com', 'http://api.github.com']) {
if (text.startsWith(prefix)) text = text.slice(prefix.length);
}
[text] = text.split('?');
text = text.replace(/\/+$/, '');
if (!text) return '';
if (!text.startsWith('/')) text = `/${text}`;
if (text.startsWith('/repos/')) {
const parts = text.split('/');
text = parts.length > 4 ? `/${parts.slice(4).join('/')}` : '/';
}
return text;
}
/** The table row for an endpoint, or null. Pure. */
export function featureFor(path) {
const key = normaliseEndpoint(path);
const row = ENDPOINT_FEATURES[key];
if (!row) return null;
const [feature, source, status, confidence] = row;
return {
endpoint: key, feature, source, status_when_disabled: status, confidence,
};
}
/** The security_and_analysis map, or null when it was not returned. Pure. */
export function securityBlock(repo) {
const block = (repo || {}).security_and_analysis;
return block && typeof block === 'object' ? block : null;
}
/** enabled, disabled or unreported for one feature. Pure. */
export function flagState(repo, feature, source) {
if (source === 'toggle') {
const value = (repo || {})[feature];
if (value === true) return 'enabled';
if (value === false) return 'disabled';
return 'unreported';
}
const block = securityBlock(repo);
if (!block) return 'unreported';
const entry = block[feature];
if (!entry || typeof entry !== 'object') return 'unreported';
const status = String(entry.status ?? '').trim().toLowerCase();
return status === 'enabled' || status === 'disabled' ? status : 'unreported';
}
/** Every endpoint with the state of the flag gating it. Pure. */
export function matrix(repo) {
return Object.keys(ENDPOINT_FEATURES).sort().map((key) => {
const row = featureFor(key);
row.state = flagState(repo, row.feature, row.source);
row.will_serve = { enabled: true, disabled: false }[row.state] ?? null;
return row;
});
}
/** Is this a repair a repository admin might not be able to make. Pure. */
export function planMayBeTheConstraint(repo, feature) {
if (!PLAN_DEPENDENT.includes(feature)) return false;
const visibility = String((repo || {}).visibility ?? '').trim().toLowerCase();
return Boolean((repo || {}).private) || ['private', 'internal'].includes(visibility);
}
/** Does the recorded status match what a disabled feature produces. Pure. */
export function statusMatches(row, observed) {
if (observed === null || observed === undefined || observed === '') return null;
const n = Number(observed);
if (!Number.isFinite(n)) return null;
return n === Number(row.status_when_disabled);
}
/** Attribute one refusal to the switch or to the grant. Pure. */
export function classify(repo, row, observedStatus = null, acceptedPermissions = null) {
if (!row) {
return ['endpoint-unknown', 'that endpoint is not one of the feature-gated '
+ 'ones in this table, so a refusal from it is not this note. Read the '
+ 'whole flag matrix above and check the permission headers instead.'];
}
const stateOfFlag = row.state ?? flagState(repo, row.feature, row.source);
const named = String(acceptedPermissions ?? '').trim();
const match = statusMatches(row, observedStatus);
if (stateOfFlag === 'unreported') {
return ['feature-unreported', `${row.feature} could not be read. The `
+ 'security_and_analysis block is only returned to a caller with admin on '
+ 'the repository, so this says something about your own role rather than '
+ 'about the feature.'];
}
if (stateOfFlag === 'disabled') {
if (match === false) {
return ['status-mismatch', `${row.feature} is disabled, but a disabled `
+ `feature answers ${row.status_when_disabled} on this endpoint and you `
+ `recorded ${observedStatus}. Fix the feature and expect the other `
+ 'failure to survive it.'];
}
return ['feature-disabled', `${row.feature} is disabled on this repository, `
+ `and ${row.status_when_disabled} is what a disabled feature produces `
+ 'here. No permission opens it.'];
}
if (named) {
return ['permission-named', `${row.feature} is enabled and the response `
+ `named '${named}' in x-accepted-github-permissions, so this is a grant `
+ 'that is missing rather than a feature that is off.'];
}
return ['feature-enabled', `${row.feature} is enabled, so the feature is not `
+ 'what refused you. Look at the credential next: a fine-grained token names '
+ 'no permission on its own refusal, and an App names one in a header.'];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state, row, repo = {}) {
const feature = (row || {}).feature || 'the feature';
if (state === 'feature-disabled') {
let text = `enable ${feature} on this repository in its security settings, `
+ 'or at organization level for every repository, then grant the caller '
+ 'the matching read permission. Both, in that order.';
if (planMayBeTheConstraint(repo, feature)) {
text += ' This is a private or internal repository, so availability '
+ 'depends on the plan as well as on the checkbox, and that part is not '
+ 'a repository setting.';
}
if ((row || {}).confidence === 'proxy') text += ` Note that ${PROXY_NOTE}`;
return text;
}
if (state === 'feature-unreported') {
return 'read the repository with an account that has admin on it, or ask an '
+ 'admin what the setting says. Do not record this as disabled: an absent '
+ 'block is a limit on your reading.';
}
if (state === 'permission-named') {
return 'grant the named permission. The feature is on, so this is the '
+ 'ordinary permissions path and not this note.';
}
if (state === 'status-mismatch') {
return 'enable the feature anyway, then diagnose the recorded status '
+ 'separately. Two causes were in play and only one of them is addressed here.';
}
if (state === 'feature-enabled') {
return 'look at the credential. Nothing about the repository features '
+ 'explains this refusal.';
}
return 'name the endpoint that refused you so the flag can be mapped to it.';
}
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 repoName = process.env.GITHUB_REPO;
if (!token || !repoName) {
console.error('set GITHUB_TOKEN (read-only is enough) and GITHUB_REPO');
process.exitCode = 2;
return;
}
console.log(`read cost: ${readCost(0)} request(s) against the core hourly quota`);
const res = await fetch(`${API}/repos/${repoName}`, { headers: headers(token) });
if (res.status !== 200) {
console.error(`${repoName}: HTTP ${res.status} reading the repository`);
process.exitCode = 2;
return;
}
const repo = await res.json();
console.log(`${repoName}: private=${repo.private}`);
if (!securityBlock(repo)) {
console.log('security_and_analysis: not returned. That block is only sent to '
+ 'a caller with admin on the repository.');
} else {
console.log(`security_and_analysis: ${SECURITY_FEATURES
.map((f) => `${f}=${flagState(repo, f, 'security')}`).join(' ')}`);
}
console.log(`toggles: ${TOGGLES.map((t) => `${t}=${repo[t]}`).join(' ')}`);
const target = process.env.GITHUB_ENDPOINT
? featureFor(process.env.GITHUB_ENDPOINT) : null;
if (target) {
target.state = flagState(repo, target.feature, target.source);
console.log(`${target.endpoint} -> ${target.feature} (${target.confidence}), `
+ `${target.status_when_disabled} when disabled`);
}
const [state, detail] = classify(repo, target, process.env.GITHUB_STATUS,
process.env.GITHUB_ACCEPTED);
console.log(`${state}: ${detail}`);
console.log(`repair: ${repair(state, target, repo)}`);
console.log(JSON.stringify({
repository: repoName,
private: repo.private,
visibility: repo.visibility,
security_block_returned: securityBlock(repo) !== null,
matrix: matrix(repo),
endpoint: target,
state,
detail,
repair: repair(state, target, repo),
}, null, 2));
process.exitCode = ['feature-disabled', 'status-mismatch'].includes(state) ? 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
The three status codes are asserted as data, because the note stands or falls on them: 403 for code scanning, 404 for secret scanning, 410 for issues, all produced by the same kind of off switch. Then the three flag states, where the one that matters is unreported — a repository object with no security block has to come back as unreported and never as disabled, since the block's absence describes the reader's role and not the repository. The rest is the classifier: a named permission beside an enabled feature is somebody else's note, a recorded status that does not match the documented one is called a mismatch rather than folded into the story, and the endpoint normaliser is fed the kind of full URL people actually paste out of a log.
from github_feature_flags import (
ENDPOINT_FEATURES, PLAN_DEPENDENT, classify, feature_for, flag_state,
matrix, normalise_endpoint, plan_may_be_the_constraint, read_cost, repair,
security_block, status_matches,
)
ADMIN_VIEW = {
"private": True,
"visibility": "private",
"has_issues": False,
"has_wiki": True,
"security_and_analysis": {
"advanced_security": {"status": "disabled"},
"secret_scanning": {"status": "disabled"},
"secret_scanning_push_protection": {"status": "disabled"},
"dependabot_security_updates": {"status": "enabled"},
},
}
# The same repository read by a collaborator without admin: no security block.
READER_VIEW = {"private": True, "visibility": "private", "has_issues": False,
"has_wiki": True}
HEALTHY = {
"private": False,
"visibility": "public",
"has_issues": True,
"security_and_analysis": {
"advanced_security": {"status": "enabled"},
"secret_scanning": {"status": "enabled"},
},
}
def test_one_off_switch_produces_three_status_codes():
assert feature_for("/code-scanning/alerts")["status_when_disabled"] == 403
assert feature_for("/secret-scanning/alerts")["status_when_disabled"] == 404
assert feature_for("/issues")["status_when_disabled"] == 410
# Only the first of those looks like a permissions problem.
def test_a_logged_url_is_reduced_to_a_table_key():
assert normalise_endpoint(
"https://api.github.com/repos/octo/pay/code-scanning/alerts?state=open"
) == "/code-scanning/alerts"
assert normalise_endpoint("/repos/octo/pay/issues") == "/issues"
assert normalise_endpoint("issues") == "/issues"
assert normalise_endpoint("") == ""
def test_an_absent_security_block_is_unreported_and_never_disabled():
assert security_block(READER_VIEW) is None
assert flag_state(READER_VIEW, "advanced_security", "security") == "unreported"
assert flag_state(ADMIN_VIEW, "advanced_security", "security") == "disabled"
assert flag_state(ADMIN_VIEW, "dependabot_security_updates", "security") == "enabled"
def test_a_toggle_is_readable_by_anybody_who_can_read_the_repo():
assert flag_state(READER_VIEW, "has_issues", "toggle") == "disabled"
assert flag_state(READER_VIEW, "has_wiki", "toggle") == "enabled"
assert flag_state(READER_VIEW, "has_discussions", "toggle") == "unreported"
def test_a_disabled_feature_is_named_and_no_permission_opens_it():
row = feature_for("/code-scanning/alerts")
state, detail = classify(ADMIN_VIEW, row, 403)
assert state == "feature-disabled"
assert "No permission opens it" in detail
def test_the_unreported_case_blames_the_readers_role_not_the_repo():
row = feature_for("/code-scanning/alerts")
state, detail = classify(READER_VIEW, row, 403)
assert state == "feature-unreported"
assert "admin on the repository" in detail
assert "absent block is a limit on your reading" in repair(state, row)
def test_an_enabled_feature_with_a_named_permission_is_somebody_elses_note():
row = feature_for("/code-scanning/alerts")
state, detail = classify(HEALTHY, row, 403, "security_events=read")
assert state == "permission-named"
assert "security_events=read" in detail
state, _ = classify(HEALTHY, row, 403, "")
assert state == "feature-enabled"
def test_a_status_that_does_not_match_is_called_a_mismatch():
row = feature_for("/secret-scanning/alerts")
assert status_matches(row, 404) is True
assert status_matches(row, 403) is False
assert status_matches(row, None) is None
state, detail = classify(ADMIN_VIEW, row, 403)
assert state == "status-mismatch"
assert "404" in detail and "403" in detail
def test_the_issues_toggle_answers_410_gone_which_reads_as_deprecation():
row = feature_for("/issues")
state, detail = classify(ADMIN_VIEW, row, 410)
assert state == "feature-disabled"
assert "410" in detail
def test_the_matrix_covers_every_endpoint_in_the_table():
rows = matrix(ADMIN_VIEW)
assert len(rows) == len(ENDPOINT_FEATURES)
by_endpoint = {r["endpoint"]: r for r in rows}
assert by_endpoint["/issues"]["will_serve"] is False
assert by_endpoint["/dependabot/alerts"]["will_serve"] is True
assert matrix(READER_VIEW)[0]["will_serve"] in (True, False, None)
def test_a_proxy_mapping_is_flagged_as_one():
assert feature_for("/dependabot/alerts")["confidence"] == "proxy"
assert feature_for("/secret-scanning/alerts")["confidence"] == "exact"
row = feature_for("/dependabot/alerts")
row["state"] = "disabled"
assert "not proof" in repair("feature-disabled", row, ADMIN_VIEW)
def test_the_plan_can_be_a_repair_an_admin_cannot_make():
assert "advanced_security" in PLAN_DEPENDENT
assert plan_may_be_the_constraint(ADMIN_VIEW, "advanced_security") is True
assert plan_may_be_the_constraint(HEALTHY, "advanced_security") is False
assert plan_may_be_the_constraint(ADMIN_VIEW, "has_issues") is False
row = feature_for("/code-scanning/alerts")
assert "depends on the plan" in repair("feature-disabled", row, ADMIN_VIEW)
def test_an_endpoint_outside_the_table_is_handed_back():
assert feature_for("/pulls") is None
state, _ = classify(ADMIN_VIEW, None, 403)
assert state == "endpoint-unknown"
def test_the_run_costs_one_read_plus_any_probes():
assert read_cost() == 1
assert read_cost(len(ENDPOINT_FEATURES)) == 1 + len(ENDPOINT_FEATURES)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ENDPOINT_FEATURES, PLAN_DEPENDENT, classify, featureFor, flagState, matrix,
normaliseEndpoint, planMayBeTheConstraint, readCost, repair, securityBlock,
statusMatches,
} from './github-feature-flags.mjs';
const ADMIN_VIEW = {
private: true,
visibility: 'private',
has_issues: false,
has_wiki: true,
security_and_analysis: {
advanced_security: { status: 'disabled' },
secret_scanning: { status: 'disabled' },
secret_scanning_push_protection: { status: 'disabled' },
dependabot_security_updates: { status: 'enabled' },
},
};
const READER_VIEW = {
private: true, visibility: 'private', has_issues: false, has_wiki: true,
};
const HEALTHY = {
private: false,
visibility: 'public',
has_issues: true,
security_and_analysis: {
advanced_security: { status: 'enabled' },
secret_scanning: { status: 'enabled' },
},
};
test('one off switch produces three status codes', () => {
assert.equal(featureFor('/code-scanning/alerts').status_when_disabled, 403);
assert.equal(featureFor('/secret-scanning/alerts').status_when_disabled, 404);
assert.equal(featureFor('/issues').status_when_disabled, 410);
});
test('a logged url is reduced to a table key', () => {
assert.equal(
normaliseEndpoint('https://api.github.com/repos/octo/pay/code-scanning/alerts?state=open'),
'/code-scanning/alerts',
);
assert.equal(normaliseEndpoint('/repos/octo/pay/issues'), '/issues');
assert.equal(normaliseEndpoint('issues'), '/issues');
assert.equal(normaliseEndpoint(''), '');
});
test('an absent security block is unreported and never disabled', () => {
assert.equal(securityBlock(READER_VIEW), null);
assert.equal(flagState(READER_VIEW, 'advanced_security', 'security'), 'unreported');
assert.equal(flagState(ADMIN_VIEW, 'advanced_security', 'security'), 'disabled');
assert.equal(flagState(ADMIN_VIEW, 'dependabot_security_updates', 'security'), 'enabled');
});
test('a toggle is readable by anybody who can read the repo', () => {
assert.equal(flagState(READER_VIEW, 'has_issues', 'toggle'), 'disabled');
assert.equal(flagState(READER_VIEW, 'has_wiki', 'toggle'), 'enabled');
assert.equal(flagState(READER_VIEW, 'has_discussions', 'toggle'), 'unreported');
});
test('a disabled feature is named and no permission opens it', () => {
const [state, detail] = classify(ADMIN_VIEW, featureFor('/code-scanning/alerts'), 403);
assert.equal(state, 'feature-disabled');
assert.match(detail, /No permission opens it/);
});
test('the unreported case blames the readers role not the repo', () => {
const row = featureFor('/code-scanning/alerts');
const [state, detail] = classify(READER_VIEW, row, 403);
assert.equal(state, 'feature-unreported');
assert.match(detail, /admin on the repository/);
assert.match(repair(state, row), /absent block is a limit on your reading/);
});
test('an enabled feature with a named permission is somebody elses note', () => {
const row = featureFor('/code-scanning/alerts');
const [state, detail] = classify(HEALTHY, row, 403, 'security_events=read');
assert.equal(state, 'permission-named');
assert.match(detail, /security_events=read/);
assert.equal(classify(HEALTHY, row, 403, '')[0], 'feature-enabled');
});
test('a status that does not match is called a mismatch', () => {
const row = featureFor('/secret-scanning/alerts');
assert.equal(statusMatches(row, 404), true);
assert.equal(statusMatches(row, 403), false);
assert.equal(statusMatches(row, null), null);
const [state, detail] = classify(ADMIN_VIEW, row, 403);
assert.equal(state, 'status-mismatch');
assert.match(detail, /404/);
});
test('the issues toggle answers 410 gone which reads as deprecation', () => {
const [state, detail] = classify(ADMIN_VIEW, featureFor('/issues'), 410);
assert.equal(state, 'feature-disabled');
assert.match(detail, /410/);
});
test('the matrix covers every endpoint in the table', () => {
const rows = matrix(ADMIN_VIEW);
assert.equal(rows.length, Object.keys(ENDPOINT_FEATURES).length);
const byEndpoint = Object.fromEntries(rows.map((r) => [r.endpoint, r]));
assert.equal(byEndpoint['/issues'].will_serve, false);
assert.equal(byEndpoint['/dependabot/alerts'].will_serve, true);
});
test('a proxy mapping is flagged as one', () => {
assert.equal(featureFor('/dependabot/alerts').confidence, 'proxy');
assert.equal(featureFor('/secret-scanning/alerts').confidence, 'exact');
const row = featureFor('/dependabot/alerts');
row.state = 'disabled';
assert.match(repair('feature-disabled', row, ADMIN_VIEW), /not proof/);
});
test('the plan can be a repair an admin cannot make', () => {
assert.ok(PLAN_DEPENDENT.includes('advanced_security'));
assert.equal(planMayBeTheConstraint(ADMIN_VIEW, 'advanced_security'), true);
assert.equal(planMayBeTheConstraint(HEALTHY, 'advanced_security'), false);
assert.equal(planMayBeTheConstraint(ADMIN_VIEW, 'has_issues'), false);
assert.match(
repair('feature-disabled', featureFor('/code-scanning/alerts'), ADMIN_VIEW),
/depends on the plan/,
);
});
test('an endpoint outside the table is handed back', () => {
assert.equal(featureFor('/pulls'), null);
assert.equal(classify(ADMIN_VIEW, null, 403)[0], 'endpoint-unknown');
});
test('the run costs one read plus any probes', () => {
assert.equal(readCost(), 1);
assert.equal(readCost(7), 8);
});
FAQ
The token has the security-events permission and still gets 403. What is missing?
The feature, not the grant. Code scanning, secret scanning and Dependabot alerts each have to be switched on for that repository before their endpoints will serve anybody, and the permission is checked separately on top of that. A caller holding exactly the permission the endpoint names is still refused while the feature is off, which is why widening the credential never changes anything. Read <code>security_and_analysis</code> on <code>GET /repos/{owner}/{repo}</code> first: if the relevant status is <code>disabled</code>, no permission you can grant will open it.
Why does secret scanning answer 404 when code scanning answers 403 for the same reason?
Because the endpoint families were built at different times with different conventions, and nobody harmonised them. It is worth memorising rather than rationalising: a disabled code-scanning feature refuses with <code>403</code>, a disabled secret-scanning feature refuses with <code>404</code>, and issues endpoints on a repository with issues switched off answer <code>410 Gone</code>. Three status codes, one cause. The 404 is the most costly of the three, because it sends people to look for a missing repository, and the 410 is the second, because it reads as a deprecated endpoint.
The repository object has no security_and_analysis block at all. Is the feature off?
Unknown, and that is a real answer rather than a hedge. GitHub only returns that block to a caller with admin on the repository, so its absence describes your access, not the repository's configuration. Recording it as disabled would be exactly the mistake this note is about, in the other direction: a confident claim built on a missing grant. Either read the repository with an account that has admin, or ask somebody who does. The plain toggles — <code>has_issues</code>, <code>has_wiki</code> — do come back to any reader, so those are still usable.
Could the script not just call the alerts endpoint and see what happens?
It can, and <code>--probe</code> does exactly that, because a GET to an alerts endpoint is a read and the same call your job already makes. What the probe cannot do is tell you <em>why</em> it was refused, which is the whole question: a 403 from a disabled feature and a 403 from a missing permission are the same bytes. The flag is the evidence and the probe is corroboration. That is also why the script reports a mismatch when the status you recorded is not the one a disabled feature produces on that endpoint, instead of assuming its own answer.
The feature is disabled and enabling it is greyed out. What now?
Then the constraint is the plan rather than the checkbox. Advanced Security features on private and internal repositories are only available where the organization's plan includes them, so <code>advanced_security: disabled</code> can mean nobody has enabled it or can mean nobody currently can. The script says which case is possible by looking at <code>private</code> and <code>visibility</code> alongside the flag. The repair then leaves engineering entirely and becomes a billing conversation, which is worth knowing an hour in rather than a fortnight in.
Related field notes
- Why the security block is missing from your view
- When a header does name the permission the endpoint wanted
- The other reason a read comes back 404
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.
- Repositories — GitHub REST API
- Code scanning — GitHub REST API
- Secret scanning — GitHub REST API
- Configuring security features for your repository — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.