Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: Build
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v7

uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
Expand All @@ -67,7 +66,7 @@ jobs:

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.39.1
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
Expand Down Expand Up @@ -95,6 +94,6 @@ jobs:
exit 1

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.39.1
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{matrix.language}}"
6 changes: 3 additions & 3 deletions .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.13.2
only-new-issues: true
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ jobs:
# goreleaser creates the GitHub release and uploads the archives.
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# goreleaser builds the changelog from history, so a shallow
# checkout would produce an empty one.
fetch-depth: 0

- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod

Expand All @@ -43,7 +43,7 @@ jobs:
echo "HOMEBREW_TAP_GITHUB_TOKEN is present. The cask will be pushed to jferrl/homebrew-tap and the binaries to jferrl/gh-githubauth."
fi

- uses: goreleaser/goreleaser-action@v7
- uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0
with:
version: "~> v2"
args: release --clean
Expand Down
9 changes: 4 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,13 @@ jobs:
with:
access_token: ${{ github.token }}

- uses: actions/checkout@v7

- uses: actions/setup-go@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ matrix.go-version }}

- name: Cache go modules
uses: actions/cache@v6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/go-build
Expand All @@ -58,6 +57,6 @@ jobs:

- name: Upload coverage to Codecov
if: ${{ matrix.update-coverage }}
uses: codecov/codecov-action@v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ githubauth token --installation 12345 --exec -- gh pr list

The same works for `git` once it takes credentials from `gh` (`gh auth setup-git`):
`githubauth token --installation 12345 --exec -- git push`. With `--enterprise-url`
or `--base-url`, `--exec` also sets `$GH_HOST` unless you already have. For the
or `--base-url`, `--exec` also sets `$GH_HOST`. An exported `$GH_HOST` naming
another instance than the installation token's exits 2 before minting, and the command
never sees `$GITHUB_APP_PRIVATE_KEY`. For the
JWT, `gh` needs the header spelled out, because it sends `token` and GitHub takes
a JWT only as `Bearer`:

Expand Down Expand Up @@ -59,7 +61,9 @@ Every flag falls back to an environment variable — `GITHUB_APP_CLIENT_ID`,
configured environment reduces the call to `githubauth token`.

`--key` takes a file path, `-` for stdin, or the PEM itself, which is what lets
the key come from a secret manager without touching disk:
the key come from a secret manager without touching disk. Pass the PEM itself
through `$GITHUB_APP_PRIVATE_KEY` or stdin, never on the command line, where the
process list shows it:

```bash
vault kv get -field=pem secret/github-app | githubauth token --key - --installation 12345
Expand Down
35 changes: 32 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

`githubauth token --exec` now revokes the token when the command exits, and the CLI can
find the installation and narrow the token on its own. Three changes can affect existing
code or scripts; they are listed under Changed.
find the installation and narrow the token on its own. A security review closed six
ways a credential could reach somewhere it should not, listed under Security. Four
changes can affect existing code or scripts; they are listed under Changed.

### Security

- `githubauth token --exec` and `jwt --exec` no longer pass `$GITHUB_APP_PRIVATE_KEY` to
the command, nor to the `git` that reads a clone's origin, in any letter case on
Windows. A child that leaked its environment leaked the key that mints tokens for every
installation, past `--repos`, `--permissions` and revocation
- A `--key` that is neither PEM nor a readable file, such as a base64-encoded key from a CI
secret, was repeated in full in the error, and so in logs and agent transcripts. The
error no longer includes a value longer than a path
- `--exec` set `$GH_HOST` without the API URL's port, so `gh` sent the credential to port
443 of that host. The port is kept, and a clone's https origin must name the same port
as the API for the token to be scoped to it
- An exported `$GH_HOST` naming another instance than an installation token's made `gh`
send the token there. `githubauth token --exec` now exits 2 before minting (see Changed).
The JWT is not checked: it is signed locally, and only you know which instance its App
lives on
- A SIGINT sent to githubauth alone, as a supervisor does, left the command running with the
credential. On Linux and macOS, unless the command is in the terminal's foreground job,
where a Ctrl-C reaches it directly, SIGINT is now passed on like SIGTERM
- Every GitHub Action in the workflows is pinned to a commit. The release job holds the
token that publishes the Homebrew cask and the gh extension, and a moved tag could have
run code with it. A successful API response is also capped at 16 MiB

### Added

- `RevokeInstallationToken` ends an installation token before its hour is up.
`githubauth token --exec` calls it once the command exits; `--keep` leaves the token
valid. A revocation that fails is reported on stderr, the token then stays valid until
it expires, and the exit code stays the command's (#87, #88)
it expires, and the exit code stays the command's. A cached source that handed the token
out keeps returning it, so build a new one (#87, #88)
- `githubauth token --permissions contents:read,pull_requests:write` scopes the token
below what the App was granted. Any permission name GitHub has is accepted; GitHub
refuses one it does not know with a 422, which exits 2 (#87, #89)
Expand All @@ -46,6 +71,10 @@ code or scripts; they are listed under Changed.
- **`githubauth token --exec` revokes the token when the command exits.** Anything the
command leaves running on the token starts getting 401s. `--keep` restores the old
behaviour (#87)
- **An exported `$GH_HOST` that differs from the installation token's instance exits 2**
under `token --exec`, before anything is minted. It used to be kept, which v1.11.0 suggested for a
`--base-url` proxy in front of github.com; set it inside the command instead:
`--exec -- env GH_HOST=github.com gh …`
- **`InstallationPermissions` is no longer comparable**, because `Other` is a map. Code
that compares two values with `==` or uses one as a map key no longer compiles (#88)
- **Comparing installation token values, `*a == *b`, now panics** when GitHub's answer
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ curl -H "Authorization: Bearer $(githubauth token)" \
Every flag falls back to an environment variable — `GITHUB_APP_CLIENT_ID`,
`GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_INSTALLATION_ID` — so a configured CI step is just
`githubauth token`. `--key` takes a file path, the PEM itself, or `-` to read stdin, which
keeps the key off disk:
keeps the key off disk. Pass the PEM itself through `$GITHUB_APP_PRIVATE_KEY` or stdin
rather than on the command line, where other local users can read it in the process list:

```bash
vault kv get -field=pem secret/github-app |
Expand Down Expand Up @@ -153,8 +154,11 @@ gh auth setup-git # once
githubauth token --installation 12345 --exec -- git push
```

With `--enterprise-url` or `--base-url`, `--exec` also sets `$GH_HOST`, so `gh` talks to
that instance instead of github.com. A `$GH_HOST` you exported yourself is kept.
With `--enterprise-url` or `--base-url`, `--exec` also sets `$GH_HOST`, port included, so
`gh` talks to that instance instead of github.com. A `$GH_HOST` you exported that names
another instance is refused before an installation token is minted, since `gh` would send
it there. If a proxy really serves it, set it inside the command:
`--exec -- env GH_HOST=github.com gh …`. The command never sees `$GITHUB_APP_PRIVATE_KEY`.

The JWT needs one more step. `gh` sends its credential as `token`, and GitHub accepts a JWT
only as `Bearer`, so pass the header yourself:
Expand Down
4 changes: 3 additions & 1 deletion auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,9 @@ func NewInstallationTokenSource(id int64, src oauth2.TokenSource, opts ...Instal
}

// RevokeInstallationToken revokes an installation access token before its hour
// is up, so a copy that leaked stops working. WithBaseURL, WithEnterpriseURL and
// is up, so a copy that leaked stops working. A source from
// NewInstallationTokenSource that handed the token out does not know, and keeps
// returning it from its cache until it expires; build a new source instead. WithBaseURL, WithEnterpriseURL and
// WithHTTPClient apply as they do to NewInstallationTokenSource. The other
// options are ignored.
//
Expand Down
49 changes: 40 additions & 9 deletions cmd/githubauth/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"runtime"
"slices"
"strings"
"syscall"
Expand Down Expand Up @@ -44,21 +46,25 @@ func runWithToken(argv []string, tok *oauth2.Token, host string, stdin io.Reader
}

cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = environWithout(os.Environ(), all...)
// The child gets an installation token for an hour, never the App's key,
// which mints tokens for every installation until it is rotated.
cmd.Env = childEnviron()
for _, key := range keys {
cmd.Env = append(cmd.Env, key+"="+tok.AccessToken)
}
// Without it gh targets github.com, where this credential is useless. A
// GH_HOST the caller exported is theirs to keep.
// GH_HOST the caller exported was checked against host before minting.
if host != "" && os.Getenv("GH_HOST") == "" {
cmd.Env = append(cmd.Env, "GH_HOST="+host)
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, stdout, stderr

// Ctrl-C already reaches the child through the terminal's process group, so
// SIGINT is only held to keep githubauth alive until the child exits. A
// SIGTERM is usually sent to githubauth alone, and is passed on so the
// child does not outlive it with the credential in its environment.
// A SIGTERM, or a SIGINT from a supervisor, is sent to githubauth alone,
// and is passed on so the child does not outlive it with the credential in
// its environment. A Ctrl-C at the keyboard reaches the child itself when
// the child is in the terminal's foreground job, so then SIGINT is only
// held, to keep githubauth alive until the child exits.
inFg := foreground
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigs)
Expand All @@ -75,7 +81,7 @@ func runWithToken(argv []string, tok *oauth2.Token, host string, stdin io.Reader
for {
select {
case sig := <-sigs:
if sig == syscall.SIGTERM {
if sig == syscall.SIGTERM || !inFg(cmd.Process.Pid) {
_ = cmd.Process.Signal(sig)
}
case <-done:
Expand Down Expand Up @@ -154,24 +160,49 @@ func ghHost(apiURL string) string {
}
host := strings.ToLower(u.Hostname())
if rest, ok := strings.CutPrefix(host, "api."); ok && !isEnterpriseHost(rest) {
return rest
host = rest
}
// gh honours a port in GH_HOST. Without it the credential would go to
// whatever answers on 443 of that host. gh speaks only https, so only its
// default can go; and a host with a port is never rewritten, since gh then
// treats it as Enterprise Server and would not put api. back.
if port := u.Port(); port != "" && (u.Scheme != "https" || port != "443") {
return net.JoinHostPort(strings.ToLower(u.Hostname()), port)
}
// An IPv6 address needs its brackets back to be a host gh can parse.
if strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}

// foreground is inForeground, replaceable by the tests, whose process group a
// developer's terminal may or may not hold.
var foreground = inForeground

// isEnterpriseHost mirrors gh's own test for whether a host reads the
// enterprise variables: anything but github.com and data residency (*.ghe.com).
func isEnterpriseHost(host string) bool {
return host != "" && host != "github.com" && !strings.HasSuffix(host, ".github.com") && !strings.HasSuffix(host, ".ghe.com")
}

// childEnviron is githubauth's environment without the App's private key or
// any token already exported, for every process githubauth starts.
func childEnviron() []string {
return environWithout(os.Environ(), append(append(slices.Clone(execTokenEnv), execEnterpriseEnv...), "GITHUB_APP_PRIVATE_KEY")...)
}

// foldEnvNames is whether environment variable names ignore case, as on
// Windows, where github_app_private_key is the same variable.
var foldEnvNames = runtime.GOOS == "windows"

// environWithout drops keys from env so the token cannot be shadowed by one
// the caller already exported.
func environWithout(env []string, keys ...string) []string {
out := make([]string, 0, len(env))
for _, kv := range env {
name, _, _ := strings.Cut(kv, "=")
if !slices.Contains(keys, name) {
if !slices.ContainsFunc(keys, func(k string) bool { return k == name || foldEnvNames && strings.EqualFold(k, name) }) {
out = append(out, kv)
}
}
Expand Down
Loading
Loading