Skip to content

feat(repo): audit GitHub settings, the one layer no in-repo gate can see - #54

Merged
stxkxs merged 2 commits into
mainfrom
feat/repo-settings-audit
Aug 7, 2026
Merged

feat(repo): audit GitHub settings, the one layer no in-repo gate can see#54
stxkxs merged 2 commits into
mainfrom
feat/repo-settings-audit

Conversation

@stxkxs

@stxkxs stxkxs commented Aug 7, 2026

Copy link
Copy Markdown
Member

Branch protection, required checks and Dependabot state live only in GitHub. Nothing inside a repository can observe them — so a repo can carry a full CI matrix, a protection rule that requires none of it, and no gate anywhere will say so.

What it reports on the org today

cloudgov repo audit --org nanohype20 findings, and their shape is the argument for the command:

state repos
protection rule requiring zero status checks landing-zone, cloudgov, homebrew-tap
no protection at all ten public repos
protection unavailable on the plan clusters, tenants, nanohype.dev

landing-zone's merges become live AWS infrastructure, and it has a rule that requires nothing — the most misleading of the three states, since the repository reads as protected in every UI while admitting a PR whose entire CI matrix is red. clusters and tenants hold live GitOps state and cannot be protected at all.

Three absences, not one

No rule · a rule requiring nothing · unavailable on the plan — three different facts, three different remedies, kept distinct end to end. Collapsing them is how "upgrade your plan" gets filed as "add a required check", and how an unprotectable repository reads as compliant because nothing was said about it.

Shape

internal/repo owns a narrow Reader interface, following the IdentityReader precedent, so the whole audit runs against fixtures with no token and no network. The gh-backed reader takes its command runner as a field for the same reason — argument construction and the three-way protection parse are asserted, not assumed.

gh rather than a GitHub SDK because it already holds the operator's credential. cloudgov reads AWS through the ambient chain and asks for nothing; this keeps that property instead of introducing the one token the tool would have to be handed.

The expected shape is committed. Every required check named in it is a job with no paths: filter — load-bearing, because a path-filtered job does not run on a PR that touches nothing it watches, so requiring it wedges that PR forever, and with enforceAdmins on there is no override left to dig out with. nanohype has 11 of 12 jobs path-filtered, which is why it requires none of them.

An unreadable repository is reported, never skipped. A sweep that skips what it cannot read reports a clean org over repositories it never looked at.

A test of mine that was wrong

The gh fixture matched invocations by substring over a Go map, and every protection URL contains the repo URL — so iteration order decided which stub answered, and the test passed about half the time. The reader was right; the fixture was not. Keys are now unambiguous and the suite runs clean at -count=5.

Verification

task build, task lint (0 issues), scripts/check-context.sh, and scripts/coverage.sh all pass — internal/repo at 82.4% against its new 80% floor, internal/output restored to 72.3% above its 70%. 27 packages green.

Reported, never enforced — consistent with the rest of the tool.

Branch protection, required checks and Dependabot state live only in GitHub.
Nothing inside a repository can observe them, so a repo can carry a full CI
matrix, a protection rule that requires none of it, and no gate anywhere will
say so.

Run against the org today it reports 20 findings, and the shape of them is the
argument for the command:

  * three repos carry a protection rule requiring ZERO status checks —
    landing-zone among them, whose merges become live AWS infrastructure. This
    is the most misleading of the states: the repository reads as protected in
    every UI while admitting a PR whose entire CI matrix is red.
  * ten have no protection at all.
  * three private repos cannot be protected on the current plan, two of which
    hold live cluster and tenant GitOps state.

─── three absences, not one ───

No rule, a rule requiring nothing, and protection unavailable on the plan are
three different facts with three different remedies, and the reader keeps them
distinct end to end. Collapsing them is how "upgrade your plan" gets filed as
"add a required check", and how an unprotectable repository reads as compliant
because nothing was reported about it.

─── shape of the implementation ───

internal/repo owns a narrow Reader interface, following the IdentityReader
precedent, so the whole audit runs against fixtures with no token and no
network. The gh-backed reader takes its command runner as a field for the same
reason — its argument construction and its three-way protection parse are
asserted rather than assumed.

gh rather than a GitHub SDK because it already holds the operator's credential.
cloudgov reads AWS through the ambient chain and asks for nothing; this keeps
that property instead of introducing the one token the tool would have to be
handed.

The expected shape is committed. Every required check named in it is a job with
NO paths: filter — load-bearing, because a path-filtered job does not run on a
PR that touches nothing it watches, so requiring it wedges that PR forever, and
with enforce_admins on there is no override left to dig out with. nanohype has
11 of 12 jobs path-filtered, which is why it requires none of them.

An unreadable repository is reported, never skipped: a sweep that skips what it
cannot read reports a clean org over repositories it never looked at, which is
the same defect this tool is pointed at everywhere else.

─── a test of mine that was wrong ───

The gh fixture matched invocations by substring over a Go map, and every
protection URL contains the repo URL — so iteration order decided which stub
answered, and the test passed about half the time. The reader was right; the
fixture was not. Keys are now unambiguous and the suite runs clean at -count=5.
Comment thread internal/repo/gh.go Fixed
gosec's pinned CI image flags G204 on the gh invocation — subprocess launched
with variable arguments. It is right to look: the argv is built from a caller's
--org flag and names returned by the API.

There is no command injection to have, because exec.Command passes argv directly
with no shell. But there is a real hazard the rule is pointing at from the wrong
angle: a name beginning with a dash lands in an argument position and gh reads it
as a FLAG rather than a path segment, which turns a read into something else.

Every value this package interpolates — org, repo, and the default branch the API
returns — is now checked against GitHub's own name character set before it
reaches an argument. The suppression that remains states what is true rather than
asking to be trusted: the command is the constant "gh", and the arguments are
literals plus already-validated tokens.

Tested from both sides, because a validator that refuses everything passes a
rejection test: the flag-shaped, path-shaped, empty and over-long names are all
refused and never reach gh at all, and the ordinary names — including
nanohype.dev and a_b — are accepted.
@stxkxs
stxkxs merged commit ae0fd22 into main Aug 7, 2026
12 checks passed
@stxkxs
stxkxs deleted the feat/repo-settings-audit branch August 7, 2026 05:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants