| name | assess-github-repo-security |
| description | Read-only audit of a GitHub repository's security posture. Gathers ref protection (rulesets AND classic branch protection), Actions token permissions, code and supply-chain features (Dependabot, secret scanning, push protection, CodeQL), and repo hygiene toggles via `gh api`, then classifies findings against essential / recommended / advanced tiers into a PASS/GAP report. Makes NO changes. Use when reviewing a repo before open-sourcing or a release, auditing a public user-owned repo whose CI auto-commits to the default branch, verifying a hardening change actually took effect, or producing a baseline security posture report for a repository.
|
| license | MIT |
| allowed-tools | Read Bash Grep |
| metadata | {"author":"Philipp Thoss","version":"1.1","domain":"git","complexity":"intermediate","language":"multi","tags":"github, security, audit, rulesets, branch-protection, dependabot, actions"} |
Assess GitHub Repository Security
Audit a GitHub repository's security posture, read-only, and classify it
against tiered best practices. This skill only reads โ it never enables,
disables, or changes any setting. Remediation is a separate concern (see
harden-github-repo-security).
When to Use
- Reviewing a repo before open-sourcing it or cutting a release
- Auditing a public, user-owned repo whose CI auto-commits to the default
branch (e.g. via
stefanzweifel/git-auto-commit-action + the default
GITHUB_TOKEN)
- Verifying that a hardening change actually took effect
- Producing a baseline posture report to track over time
Inputs
- Required: Target repository as
OWNER/REPO
- Required:
gh CLI authenticated with an account that has the
admin repository role on the target repo (some endpoints 403
otherwise). This is a role requirement, not a need for a broad
write-capable token: a fine-grained, single-repo PAT scoped to
read-only Administration / Actions / Secret-scanning permissions
satisfies the same admin check with far less blast radius if the
audit credential is ever leaked or reused in automation
- Optional: Branch to check for classic protection (defaults to the
repo's
default_branch)
- Optional:
security_events token scope โ needed only to count open
Dependabot alerts; the rest works without it
Set a shorthand for every command below:
R="OWNER/REPO"
Procedure
Step 1: Preflight โ auth and admin
Confirm the CLI is authenticated and the account has admin on the repo.
Non-admins get partial data (403 on rulesets, Actions, and security
endpoints), which silently understates the posture.
gh auth status
gh api "repos/$R" --jq '{full_name, admin: .permissions.admin, visibility}'
Expected: gh auth status shows a logged-in account; the second call
prints admin: true.
On failure: If not authenticated, run gh auth login (or set
GH_TOKEN). If admin: false, stop and note in the report that findings
are incomplete โ a non-admin cannot read ruleset internals or Actions
permissions, so absence of a control cannot be distinguished from lack of
access.
Step 2: Repo basics โ visibility, merge toggles, collaborators
gh api "repos/$R" --jq '{visibility, private, allow_forking,
default_branch, allow_merge_commit, allow_squash_merge,
allow_rebase_merge, delete_branch_on_merge}'
gh api "repos/$R/collaborators?affiliation=direct" \
--jq '.[] | {login, role_name}'
Expected: JSON for the repo toggles plus one line per direct
collaborator with role_name (admin/maintain/write/triage/read).
On failure: A 403 on collaborators means the token lacks admin; note
it and continue. Record visibility โ it changes which features are
free: on public repos secret scanning, push protection, CodeQL,
and dependency review are free; the paid GHAS/Secret Protection products
are private/org-only, so their absence on a public repo is not a gap.
Step 3: Ref protection โ check rulesets AND classic (both)
A repo can be protected by a ruleset, by classic branch
protection, by both, or by neither. Rulesets are the actively-developed
path and are free on public user-owned repos. You MUST check both โ a repo
with only a ruleset returns 404 on the classic endpoint and vice versa.
b=$(gh api "repos/$R" --jq '.default_branch')
gh api "repos/$R/rulesets" --jq '.[] | {id, name, enforcement, target}'
gh api -i "repos/$R/branches/$b/protection" 2>/dev/null | head -1
gh api "repos/$R/branches/$b/protection" \
--jq '{required_status_checks: .required_status_checks.checks,
strict: .required_status_checks.strict,
enforce_admins: .enforce_admins.enabled,
required_reviews: .required_pull_request_reviews.required_approving_review_count,
linear: .required_linear_history.enabled,
signatures: .required_signatures.enabled,
allow_force_pushes: .allow_force_pushes.enabled,
allow_deletions: .allow_deletions.enabled}' 2>/dev/null \
|| echo "no classic branch protection"
Aggregate ALL rulesets, not just the first. Several rulesets can target the
same branch at once; GitHub applies them together (most-restrictive-wins), and a
common pattern splits ref protection (deletion/non_fast_forward, no bypass)
and a required check (required_status_checks, with bypass actors) into
separate rulesets. The branch's true posture is the union of every active
ruleset plus classic protection โ inspect each id from step (a); never conclude
from one ruleset alone. A rule absent from ruleset X may be enforced by ruleset Y.
Expected: Either a ruleset with rules such as deletion,
non_fast_forward, pull_request, required_status_checks, or a classic
protection object, or an explicit "none" from both.
On failure: A 404 on either endpoint means that mechanism is not
configured โ it is a valid data point, not an error; record "none" for
that mechanism. Note the rule set from the ruleset rules[].type list and
the bypass_actors. Remember: the default GITHUB_TOKEN /
github-actions[bot] can never be a bypass actor by design โ if the repo
auto-commits AND has required_status_checks/pull_request rules with no
Integration (GitHub App) or DeployKey bypass actor, flag that the bot
push must be 403ing (a real finding).
Step 4: Actions security โ token defaults and PR approval
gh api "repos/$R/actions/permissions" \
--jq '{enabled, allowed_actions, sha_pinning_required}'
gh api "repos/$R/actions/permissions/workflow" \
--jq '{default_workflow_permissions, can_approve_pull_request_reviews}'
Expected: default_workflow_permissions is read (hardened) or
write (permissive default), and can_approve_pull_request_reviews is
false (hardened) or true (a review-bypass vector).
On failure: 403 means non-admin โ note it. If
allowed_actions is all, any action can run (looser); selected with a
pinned allow-list is tighter โ but note that selected +
github_owned_allowed still blocks non-GitHub actions like
stefanzweifel/git-auto-commit-action unless explicitly allow-listed.
sha_pinning_required may be absent/null on repos that never set the
2025-08 policy โ record as "not enforced". Per-workflow permissions:
blocks live in the YAML, not the API; note that the API shows only the
repo default, which is a default (not a cap) for same-repo push
events.
Step 5: Code and supply-chain features
gh api -i "repos/$R/vulnerability-alerts" 2>/dev/null | head -1
gh api "repos/$R/automated-security-fixes" \
--jq '{enabled, paused}' 2>/dev/null || echo "automated-security-fixes: off/unavailable"
gh api "repos/$R" --jq '.security_and_analysis'
gh api "repos/$R/code-scanning/default-setup" --jq '.state' 2>/dev/null \
|| echo "code-scanning default setup: not configured / no access"
gh api -i "repos/$R/contents/.github/dependabot.yml" 2>/dev/null | head -1
for p in SECURITY.md docs/SECURITY.md .github/SECURITY.md; do
echo "$p: $(gh api -i "repos/$R/contents/$p" 2>/dev/null | head -1)"
done
gh api "repos//private-vulnerability-reporting" --jq 2>/dev/null \
||
gh api --jq 2>/dev/null \
||
Expected: vulnerability-alerts โ HTTP/2.0 204 when enabled;
security_and_analysis shows secret_scanning.status and
secret_scanning_push_protection.status as enabled on a hardened public
repo; code-scanning/default-setup .state is configured when CodeQL
default setup is on; automated-security-fixes โ enabled: true; the
dependabot.yml probe returns 200 when present; at least one of the
three SECURITY.md paths returns 200 when a policy file exists;
private-vulnerability-reporting .enabled prints true when PVR is on.
On failure: vulnerability-alerts returning 404 = Dependabot alerts
OFF (a gap). Remember alerts != fixes: alerts on with
automated-security-fixes off means nothing is auto-remediated โ flag
both separately. On public repos secret_scanning is usually already
enabled by default; if security_and_analysis omits the field entirely,
treat it as advisory (public-repo default on) but note the API did not
confirm it. code-scanning/default-setup 404/non-configured = CodeQL
default setup not enabled (a recommended-tier gap on a public repo, where
code scanning is free). For SECURITY.md, record a GAP only if all three
paths 404 โ a 200 on root, docs/, or .github/ all count as
present. private-vulnerability-reporting empty/false = PVR off; a
non-JSON or error response = not accessible. dependabot/alerts 403 is
expected without security_events scope โ record "not assessed", not "0".
Step 6: Produce the tiered PASS/GAP report
Classify every gathered fact into three tiers and mark PASS (control
present), GAP (control absent), or N/A (not applicable, e.g. a paid
private-repo feature on a public repo). Distinguish a required check
from an advisory one: a status check that runs but is not listed in the
ruleset's required_status_checks does not gate anything โ it is advisory
only.
Interpret the solo-maintainer lockout. Cross-reference the Step 2
direct-collaborator count with the Step 3 required_approving_review_count.
On a single-maintainer repo, required_approving_review_count >= 1 (and
require_code_owner_review with a sole owner) is unsatisfiable โ you
cannot approve your own PR โ so on an auto-commit repo it is a self-lockout
/ functional breakage, not a PASS. Flag it as a GAP-with-caveat, and
note that a ruleset (unlike classic branch protection's enforce_admins)
does not auto-exempt the admin: the sole maintainer stays blocked unless
explicitly added to the ruleset bypass_actors.
Do not publish the raw report into the audited repo. The PASS/GAP list
enumerates exact, admin-only-visible gaps (no ruleset, GITHUB_TOKEN can
approve PRs, no push protection) โ a pre-remediation vulnerability roadmap.
While any GAP is open, keep the findings local/private (a gitignored
file, a private gist, or a draft GitHub Security Advisory); never commit it
into the public repo being audited (contrast security-audit-codebase,
which writes SECURITY_AUDIT_REPORT.md into the project root โ do NOT copy
that pattern here). Redact ruleset / App ID / bypass-actor identifiers
before any public write.
# GitHub Repo Security Assessment โ OWNER/REPO
Date: YYYY-MM-DD Visibility: public Auditor role: admin
## Essential
- [PASS] Ruleset with deletion + non_fast_forward on default branch
- [GAP] default_workflow_permissions = write (should be read)
- [PASS] can_approve_pull_request_reviews = false
- [GAP] actions/checkout pinned to @v4 tag, not a 40-char SHA
- [PASS] Dependabot alerts (204) + security updates (enabled)
- [GAP] No .github/dependabot.yml (version updates off โ keeps
github-actions action pins fresh; essential supply-chain hygiene)
- [PASS] Secret scanning + push protection enabled
## Recommended
- [N/A] required_status_checks โ none (no CI gate configured)
- [PASS] CodeQL default setup: configured
- [PASS] SECURITY.md present (.github/) + PVR enabled
## Advanced
- [GAP] sha_pinning_required: not enforced
- [N/A] Required signed commits โ would block the auto-commit bot
- [N/A] Paid GHAS / Secret Protection โ public repo, features free
## Notes
- Auto-commit bot: ruleset has NO App/DeployKey bypass actor; if
required_status_checks were added the github-actions[bot] push would 403.
- Solo maintainer (1 direct collaborator): required_approving_review_count
is 0 โ correct; any value >= 1 would be an unsatisfiable self-lockout and
a ruleset would NOT auto-exempt the admin.
- Dependabot open alerts: not assessed (token lacks security_events).
- This report is kept local/private โ not committed into the audited repo
while GAPs remain open.
Expected: A written report with every fact placed in a tier and
marked PASS / GAP / N/A, plus a Notes section for auth gaps, advisory-only
checks, and the auto-commit-bot bypass interaction.
On failure: If some endpoints 403'd, still emit the report but mark
those rows "not assessed" and state the missing scope/role at the top โ
never record an unreadable control as a GAP (absence of access is not
absence of the control).
Validation
Common Pitfalls
- Checking only rulesets or only classic protection: They are
independent mechanisms. A repo protected by a ruleset returns
404 on
branches/{b}/protection; concluding "unprotected" from that alone is
wrong. Always query both.
- Some endpoints 403 without security scope/admin:
dependabot/alerts
needs security_events; rulesets and Actions permissions need admin. A
403 is "not assessed", not a GAP โ recording it as a gap fabricates a
finding.
- Misreading the auto-commit-bot bypass: The default
GITHUB_TOKEN /
github-actions[bot] can never be a ruleset bypass actor. If a repo
auto-commits to a branch that has required_status_checks or a
pull_request rule with no Integration/DeployKey bypass actor, the
bot push is being rejected โ surface it as a real finding.
- Reading the API default as a hard cap:
default_workflow_permissions
is the repo default; per-job permissions: in the workflow YAML can
raise or drop it for same-repo events. The API cannot show the effective
per-workflow grant โ note that limitation.
- Inferring CodeQL from
security_and_analysis: that object carries
secret_scanning*, dependabot_security_updates, and advanced_security
โ but no code-scanning field. CodeQL default-setup state comes only
from the code-scanning/default-setup endpoint; never claim a CodeQL
PASS/GAP the skill did not actually probe.
- Marking a solo review requirement as PASS: on a one-maintainer repo,
required_approving_review_count >= 1 is an unsatisfiable self-lockout,
not a protective control โ and a ruleset (unlike classic enforce_admins)
will not auto-exempt the admin. Report it as a functional-breakage GAP.
Related Skills
harden-github-repo-security - apply the fixes this audit surfaces
security-audit-codebase - complementary in-tree secret/dependency scan
configure-git-repository - foundational repo + .gitignore setup