{
  "generatedAt": "2026-10-12T16:01:05.493Z",
  "note": "Places where GitHub access works differently from what most people expect, each with the GitHub docs or changelog that states it. refs point at ghtier pages (see data/refs.ts): permission:<key>:<level>, role:<id>, scope:<name>, key:<id>. setups is left out when a finding applies on every setup.",
  "findings": [
    {
      "id": "sso-removal-keeps-tokens",
      "title": "Removing someone in the IdP does not stop their tokens",
      "expected": "When a person is removed or disabled in the identity provider, they lose access to the organization.",
      "actual": "With SAML SSO and no SCIM, removing a user in the IdP does not remove them from the organization. Their SSO session expires, but personal access tokens, SSH keys and OAuth tokens they already authorized for SSO keep working until an owner removes the member or revokes the authorizations. With SCIM the member is removed automatically. With Enterprise Managed Users the account is suspended and its tokens and keys are deleted.",
      "check": "Compare the organization's SSO credential authorizations with the people who are still active in the IdP. Anyone who is gone from the IdP but still listed can use those credentials.",
      "command": "gh api /orgs/ORG/credential-authorizations --paginate \\\n  --jq '.[] | [.login, .credential_type, .credential_authorized_at, .credential_accessed_at] | @tsv'",
      "fix": "Turn on SCIM provisioning so removal in the IdP removes the member. Until then, make removing the GitHub member and revoking their credential authorizations part of offboarding (DELETE /orgs/{org}/credential-authorizations/{credential_id}).",
      "refs": [
        "key:organization-saml-sso",
        "key:enterprise-saml-sso",
        "key:enterprise-emu-provisioning",
        "key:ssh-key",
        "role:org-member"
      ],
      "setups": [
        "enterprise-cloud"
      ],
      "sources": [
        {
          "label": "GitHub Docs: About SCIM for organizations",
          "url": "https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-saml-single-sign-on-for-your-organization/about-scim-for-organizations"
        },
        {
          "label": "GitHub Docs: Deprovisioning and reinstating users (EMU)",
          "url": "https://docs.github.com/en/enterprise-cloud@latest/admin/managing-iam/provisioning-user-accounts-with-scim/deprovisioning-and-reinstating-users"
        },
        {
          "label": "GitHub REST: SAML SSO authorizations for an organization",
          "url": "https://docs.github.com/en/enterprise-cloud@latest/rest/orgs/orgs?apiVersion=2022-11-28#list-saml-sso-authorizations-for-an-organization"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "repo-scope-changes-org-settings",
      "title": "The repo scope can change organization settings",
      "expected": "A classic token needs admin:org to change organization settings; repo is about repositories.",
      "actual": "GitHub's REST docs accept admin:org or repo for PATCH /orgs/{org}, and list repo for writing organization Actions secrets and creating runner registration tokens. A classic token with only repo, created by an organization owner, can change the organization's settings, including the base repository permission for every member, without ever asking for admin:org.",
      "check": "Find organization owners who use classic tokens. In an organization with SAML SSO, the credential authorizations list shows each token's scopes.",
      "command": "gh api /orgs/ORG/credential-authorizations --paginate \\\n  --jq '.[] | select(.scopes | index(\"repo\")) | [.login, .credential_authorized_at] | @tsv'",
      "fix": "Have owners use fine-grained tokens or a separate account for automation, and restrict classic tokens in the organization's personal access token policy.",
      "refs": [
        "scope:repo",
        "scope:admin:org",
        "role:org-owner",
        "permission:organization_administration:write",
        "permission:organization_secrets:write",
        "permission:organization_self_hosted_runners:write",
        "key:organization-base-permission"
      ],
      "sources": [
        {
          "label": "GitHub REST: Update an organization",
          "url": "https://docs.github.com/en/rest/orgs/orgs?apiVersion=2022-11-28#update-an-organization"
        },
        {
          "label": "GitHub REST: Actions secrets",
          "url": "https://docs.github.com/en/rest/actions/secrets?apiVersion=2022-11-28"
        },
        {
          "label": "GitHub REST: Self-hosted runners",
          "url": "https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "write-reads-all-secrets",
      "title": "Write access reads every repository secret",
      "expected": "Secrets are hidden. People who can push code cannot see them.",
      "actual": "GitHub states that any user with write access to a repository has read access to all secrets configured in it: they can push a workflow on a branch that prints or sends them. Environment secrets are only given to jobs that use the environment, and an environment with required reviewers holds the job until someone approves it.",
      "check": "List the repository's secrets and who has write access. Every person and token with write can read each of those secrets.",
      "command": "gh api /repos/ORG/REPO/actions/secrets --jq '.secrets[].name'\ngh api /repos/ORG/REPO/collaborators --paginate \\\n  --jq '.[] | select(.permissions.push) | .login'",
      "fix": "Move deployment secrets into environments with required reviewers, or replace them with OIDC so there is no stored secret to read.",
      "refs": [
        "role:repo-write",
        "permission:contents:write",
        "permission:workflows:write",
        "key:environment-protection-rules",
        "key:environment-reviewer"
      ],
      "sources": [
        {
          "label": "GitHub Docs: Secure use reference",
          "url": "https://docs.github.com/en/actions/reference/security/secure-use"
        },
        {
          "label": "GitHub Docs: Deployments and environments",
          "url": "https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "workflow-file-without-workflow-scope",
      "title": "Workflow files can be pushed without the workflow scope",
      "expected": "A classic token without the workflow scope cannot add or change workflow files.",
      "actual": "GitHub's scope docs say a workflow file can be committed without the workflow scope when the same file, with the same path and contents, already exists on another branch of the repository. A workflow that sits on any branch can be copied to another branch with a token that only has repo.",
      "check": "Look for workflow files on branches other than the default branch, especially ones that use secrets or pull_request_target.",
      "fix": "Protect .github/workflows with a push ruleset or code owner review, and delete stale branches that carry workflow files.",
      "refs": [
        "scope:workflow",
        "scope:repo",
        "permission:workflows:write"
      ],
      "sources": [
        {
          "label": "GitHub Docs: Scopes for OAuth apps",
          "url": "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "old-github-token-defaults",
      "title": "Older organizations still give GITHUB_TOKEN write access",
      "expected": "GITHUB_TOKEN is read-only unless a workflow asks for more.",
      "actual": "Read-only became the default on 2 February 2023, but only for new enterprises, organizations and repositories. Anything created earlier kept read and write for every workflow without a permissions block.",
      "check": "Read the organization's and each repository's default workflow permissions.",
      "command": "gh api /orgs/ORG/actions/permissions/workflow\ngh api /repos/ORG/REPO/actions/permissions/workflow",
      "fix": "Set the default to read at the enterprise or organization level, and give each workflow a permissions block with what it needs.",
      "refs": [
        "permission:contents:write",
        "key:organization-actions-policy",
        "page:/tier/tokens#token-types"
      ],
      "sources": [
        {
          "label": "GitHub Changelog: default GITHUB_TOKEN permissions to read-only (2023-02-02)",
          "url": "https://github.blog/changelog/2023-02-02-github-actions-updating-the-default-github_token-permissions-to-read-only/"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "github-token-approves-prs",
      "title": "GITHUB_TOKEN may approve pull requests in older repositories",
      "expected": "A workflow cannot approve its own pull request, so required reviews always mean a person looked.",
      "actual": "The setting that lets Actions approve pull requests arrived in January 2022 turned on, so existing workflows kept working; it was later renamed \"Allow GitHub Actions to create and approve pull requests\". Only new personal repositories are documented as having it off. Where it is on, a workflow with pull-requests: write can approve a pull request and count towards required reviews.",
      "check": "Check can_approve_pull_request_reviews for the organization and its repositories.",
      "command": "gh api /orgs/ORG/actions/permissions/workflow --jq .can_approve_pull_request_reviews",
      "fix": "Turn the setting off at the enterprise or organization level unless a workflow needs it.",
      "refs": [
        "permission:pull_requests:write",
        "key:organization-actions-policy"
      ],
      "sources": [
        {
          "label": "GitHub Changelog: prevent GitHub Actions from approving pull requests (2022-01-14)",
          "url": "https://github.blog/changelog/2022-01-14-github-actions-prevent-github-actions-from-approving-pull-requests"
        },
        {
          "label": "GitHub Changelog: prevent GitHub Actions from creating and approving pull requests (2022-05-03)",
          "url": "https://github.blog/changelog/2022-05-03-github-actions-prevent-github-actions-from-creating-and-approving-pull-requests/"
        },
        {
          "label": "GitHub Docs: Managing GitHub Actions settings for a repository",
          "url": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "admins-bypass-branch-protection",
      "title": "Branch protection does not apply to admins by default",
      "expected": "A protected branch requires a review from everyone.",
      "actual": "By default a branch protection rule does not apply to people with admin on the repository, or to custom roles with \"bypass branch protections\". Rulesets have an explicit bypass list instead.",
      "check": "Check whether each protected branch also enforces its rules for admins.",
      "command": "gh api /repos/ORG/REPO/branches/main/protection --jq .enforce_admins.enabled",
      "fix": "Turn on \"Do not allow bypassing the above settings\", or move to rulesets with a short, named bypass list.",
      "refs": [
        "role:repo-admin",
        "role:org-owner",
        "permission:administration:write",
        "key:ruleset-bypass"
      ],
      "sources": [
        {
          "label": "GitHub Docs: About protected branches",
          "url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "ruleset-exempt-no-audit",
      "title": "An exempt ruleset bypass leaves no audit entry",
      "expected": "Every time someone bypasses a ruleset, it is logged.",
      "actual": "A bypass actor with bypass_mode exempt is not checked against the rules at all, and GitHub creates no bypass audit entry for it.",
      "check": "Look for exempt bypass actors in organization and repository rulesets.",
      "command": "for id in $(gh api /orgs/ORG/rulesets --jq '.[].id'); do\n  gh api /orgs/ORG/rulesets/$id --jq '.name as $n | .bypass_actors[] | select(.bypass_mode == \"exempt\") | [$n, .actor_type, .actor_id] | @tsv'\ndone",
      "fix": "Use the always or pull_request bypass modes, which are logged, unless the actor is an automation you trust not to need review.",
      "refs": [
        "key:ruleset-bypass"
      ],
      "sources": [
        {
          "label": "GitHub REST: Repository rules",
          "url": "https://docs.github.com/en/rest/repos/rules?apiVersion=2022-11-28"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "deploy-key-outlives-user",
      "title": "Deploy keys keep working after the person leaves",
      "expected": "Removing someone from the organization removes the access they set up.",
      "actual": "A deploy key belongs to the repository, not to the person who added it. It stays active when that person is removed, and it never expires.",
      "check": "List deploy keys with write access and ask who holds each private key today.",
      "command": "gh api /repos/ORG/REPO/keys --jq '.[] | select(.read_only == false) | [.id, .title, .created_at] | @tsv'",
      "fix": "Rotate deploy keys when their owner leaves, prefer read-only keys, and use a GitHub App where write access is needed.",
      "refs": [
        "key:deploy-key",
        "permission:administration:write"
      ],
      "sources": [
        {
          "label": "GitHub Docs: Managing deploy keys",
          "url": "https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "app-stays-after-installer-leaves",
      "title": "A GitHub App stays installed when its installer leaves",
      "expected": "Integrations someone added go away with them.",
      "actual": "A GitHub App stays installed if the person who set it up leaves the organization. Its private keys do not expire either; they must be revoked by hand, and anyone holding one can mint installation tokens.",
      "check": "List the organization's installations and confirm an owner for each, and the age of each App's private keys.",
      "command": "gh api /orgs/ORG/installations --paginate \\\n  --jq '.installations[] | [.app_slug, .created_at, .repository_selection] | @tsv'",
      "fix": "Give every App a named owner, remove unused installations, and rotate private keys on a schedule or keep them in a KMS.",
      "refs": [
        "key:app-private-key",
        "role:org-app-manager"
      ],
      "sources": [
        {
          "label": "GitHub Docs: Differences between GitHub Apps and OAuth apps",
          "url": "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps"
        },
        {
          "label": "GitHub Docs: Managing private keys for GitHub Apps",
          "url": "https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "classic-tokens-live-until-unused",
      "title": "Classic tokens only die after a year unused",
      "expected": "Tokens expire.",
      "actual": "Classic personal access tokens have no expiration requirement and OAuth app tokens are long-lived by default. GitHub revokes them automatically only when they have not been used for a year, so a token used now and then lives on.",
      "check": "In an organization with SAML SSO, the credential authorizations list shows each token's expiry and last use.",
      "command": "gh api /orgs/ORG/credential-authorizations --paginate \\\n  --jq '.[] | select(.credential_type == \"personal access token\") | [.login, .authorized_credential_expires_at // \"never\", .credential_accessed_at] | @tsv'",
      "fix": "Restrict classic tokens in the organization's personal access token policy and use fine-grained tokens or GitHub Apps, which do expire.",
      "refs": [
        "scope:repo",
        "page:/tier/tokens#token-types"
      ],
      "sources": [
        {
          "label": "GitHub Docs: Token expiration and revocation",
          "url": "https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation"
        },
        {
          "label": "GitHub Docs: Setting a personal access token policy for your organization",
          "url": "https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "fine-grained-pat-reads-public-repos",
      "title": "A fine-grained token reads every public repository",
      "expected": "A fine-grained token can only reach the repositories selected for it.",
      "actual": "Every fine-grained token also has read-only access to all public repositories on GitHub, whatever repositories are selected.",
      "check": "Nothing to check per token; treat public repositories as readable by any token.",
      "fix": "Keep anything sensitive out of public repositories. The repository selection only limits private and internal ones.",
      "refs": [
        "permission:contents:read",
        "page:/tier/tokens#token-types"
      ],
      "sources": [
        {
          "label": "GitHub Docs: Managing your personal access tokens",
          "url": "https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "fork-network-keeps-data",
      "title": "Deleting a fork does not delete its commits",
      "expected": "Deleting a fork removes what was pushed to it.",
      "actual": "Git data from any repository in a fork network can be read from any other repository in the same network, including the upstream, even after a fork is deleted. A commit pushed to a fork stays reachable by its hash.",
      "check": "Assume anything ever pushed to any fork of a public repository is still readable.",
      "fix": "Rotate any secret that was pushed to a fork. Deleting the fork or the branch does not remove it.",
      "refs": [
        "permission:contents:read",
        "role:repo-read"
      ],
      "sources": [
        {
          "label": "GitHub Docs: About permissions and visibility of forks",
          "url": "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-permissions-and-visibility-of-forks"
        },
        {
          "label": "GitHub Docs: What happens to forks when a repository is deleted or changes visibility",
          "url": "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/what-happens-to-forks-when-a-repository-is-deleted-or-changes-visibility"
        }
      ],
      "added": "2026-10-11"
    },
    {
      "id": "push-ruleset-bypass-fork-network",
      "title": "A push ruleset bypass reaches every fork",
      "expected": "A bypass on a repository's push ruleset only applies to that repository, and each fork's admins decide their own.",
      "actual": "Push rulesets cover a private or internal repository and its entire fork network, and so does their bypass list: only those who can bypass the ruleset in the root repository can bypass it in any fork. A bypass actor added for the root can push blocked files to every fork, and a fork's own admins cannot bypass it.",
      "check": "List the bypass actors on push rulesets for private and internal repositories that have forks.",
      "command": "gh api /repos/ORG/REPO/rulesets --jq '.[] | select(.target == \"push\") | .id' |\n  xargs -I{} gh api /repos/ORG/REPO/rulesets/{} --jq '[.name, (.bypass_actors[] | \"\\(.actor_type) \\(.actor_id) \\(.bypass_mode)\")] | @tsv'",
      "fix": "Keep push ruleset bypass lists empty or to a break-glass team, since each entry applies across the fork network.",
      "refs": [
        "key:ruleset-bypass",
        "permission:administration:write"
      ],
      "setups": [
        "team",
        "enterprise-cloud",
        "emu",
        "ghes"
      ],
      "sources": [
        {
          "label": "GitHub Docs: Available rules for rulesets",
          "url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets"
        }
      ],
      "added": "2026-10-12"
    },
    {
      "id": "bot-pull-requests-wait-for-approval",
      "title": "Pull requests a workflow opens need a person to start their checks",
      "expected": "A pull request opened by a workflow with GITHUB_TOKEN either runs its checks like any other, or runs nothing at all.",
      "actual": "Events made with GITHUB_TOKEN do not start workflow runs, with exceptions. A pull request it opens or updates does start runs, but they wait for approval, and someone with write access has to approve them from the merge box. GitHub's suggested way around the wait is a GitHub App or personal access token, whose pull requests run without approval.",
      "check": "Find workflows that open or update pull requests, and see which token they use.",
      "fix": "Read the pull request before approving its runs. Where a bot needs runs without approval, use a GitHub App with the fewest permissions, not a personal access token, and keep its pull requests behind required reviews.",
      "refs": [
        "permission:pull_requests:write",
        "permission:contents:write",
        "key:actions-create-approve-prs"
      ],
      "sources": [
        {
          "label": "GitHub Docs: GITHUB_TOKEN",
          "url": "https://docs.github.com/en/actions/concepts/security/github_token"
        }
      ],
      "added": "2026-10-12"
    }
  ]
}
