Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 9 additions & 3 deletions .github/actions/mint-token/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,20 @@ runs:
REPOS_JSON=$(echo "$REPOS" | jq -Rc 'split(",") | map(select(length > 0))')
BODY=$(jq -nc --arg role "$ROLE" --argjson repos "$REPOS_JSON" \
'{role: $role, repos: $repos}')
TOKEN=$(curl -sSf --retry 5 --retry-delay 5 --retry-all-errors \
echo "Requesting token: role=$ROLE repos=$REPOS"
MINT_RESPONSE=$(curl -sSf --retry 5 --retry-delay 5 --retry-all-errors \
-H "Authorization: Bearer $OIDC_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${MINT_URL}/v1/token" | jq -r '.token')
"${MINT_URL}/v1/token")
TOKEN=$(echo "$MINT_RESPONSE" | jq -r '.token')
echo "::add-mask::$TOKEN"
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
Comment on lines +54 to 62

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — MINT_RESPONSE variable holds raw token without masking

MINT_RESPONSE holds the full JSON response (including the plaintext token) and is never masked. While TOKEN is masked after extraction, MINT_RESPONSE itself persists for the rest of the step. If ACTIONS_STEP_DEBUG is enabled or a future change echoes this variable, the token could leak. Previously the token was piped directly through jq and never stored in a named variable.

Suggestion — add echo "::add-mask::$MINT_RESPONSE" immediately after the curl, or unset MINT_RESPONSE after extracting all fields:

MINT_RESPONSE=$(curl -sSf --retry 5 --retry-delay 5 --retry-all-errors \
  -H "Authorization: Bearer $OIDC_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$BODY" \
  "${MINT_URL}/v1/token")
echo "::add-mask::$MINT_RESPONSE"
TOKEN=$(echo "$MINT_RESPONSE" | jq -r '.token')
echo "::add-mask::$TOKEN"

Flagged by 4/5 review agents.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed both in 7aedecc. Masked MINT_RESPONSE right after curl, and moved the null/empty check before the token mask so we never register "null" as a secret.

Comment on lines 58 to 62

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — Masking "null" string breaks subsequent logging on mint failure

::add-mask::$TOKEN runs before the null/empty check. If the mint returns "null" for the token, the literal string "null" gets registered as a secret, causing all subsequent occurrences of "null" in workflow logs to be replaced with *** for the remainder of the job.

Suggestion — move validation before the mask:

TOKEN=$(echo "$MINT_RESPONSE" | jq -r '.token')
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
  echo "::error::Token mint returned no token for role=$ROLE"
  exit 1
fi
echo "::add-mask::$TOKEN"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered in the same commit — token validation now runs before the mask.

echo "::error::Token mint returned no token for role=$ROLE"
exit 1
fi
echo "::add-mask::$TOKEN"
GRANTED_REPOS=$(echo "$MINT_RESPONSE" | jq -r '.granted_repos | if . then map(strings) | join(",") else empty end')
GRANTED_PERMS=$(echo "$MINT_RESPONSE" | jq -r '.granted_permissions | if . then to_entries | map("\(.key)=\(.value)") | join(",") else empty end')
REPO_SELECTION=$(echo "$MINT_RESPONSE" | jq -r '.repository_selection // empty')
echo "Granted scope: repos=${GRANTED_REPOS:-} permissions=${GRANTED_PERMS:-} repo_selection=${REPO_SELECTION:-}"
echo "token=$TOKEN" >> "$GITHUB_OUTPUT"
45 changes: 34 additions & 11 deletions internal/dispatch/gcf/mintsrc/mintcore/github.go.embed
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,23 @@ type installationResponse struct {

// installationTokenResponse is the response from POST /app/installations/{id}/access_tokens.
type installationTokenResponse struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
Permissions map[string]string `json:"permissions,omitempty"`
Repositories []installationTokenRepository `json:"repositories,omitempty"`
RepositorySelection string `json:"repository_selection,omitempty"`
}

// installationTokenRepository is a repo entry in the installation token response.
type installationTokenRepository struct {
FullName string `json:"full_name"`
}

// GrantedScope holds the actual scope GitHub granted for the installation token.
type GrantedScope struct {
Repos []string
Permissions map[string]string
RepoSelection string
}

// canonicalRolePermissions defines the minimum GitHub App permissions per agent role.
Expand Down Expand Up @@ -181,10 +196,10 @@ func FindInstallation(ctx context.Context, httpClient HTTPDoer, githubBaseURL, j

// CreateInstallationToken exchanges a JWT for an installation access token,
// scoped to the given repos and role-specific permissions.
func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, role string, repos []string) (string, string, error) {
func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, role string, repos []string) (string, string, *GrantedScope, error) {
perms := RolePermissionsFor(role)
if perms == nil {
return "", "", fmt.Errorf("no permissions defined for role %q", role)
return "", "", nil, fmt.Errorf("no permissions defined for role %q", role)
}
tokenReqBody := map[string]interface{}{
"repositories": repos,
Expand All @@ -193,37 +208,45 @@ func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBas

tokenReqBytes, err := json.Marshal(tokenReqBody)
if err != nil {
return "", "", fmt.Errorf("marshaling token request: %w", err)
return "", "", nil, fmt.Errorf("marshaling token request: %w", err)
}

reqURL := fmt.Sprintf("%s/app/installations/%d/access_tokens", githubBaseURL, installationID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(tokenReqBytes))
if err != nil {
return "", "", fmt.Errorf("creating token request: %w", err)
return "", "", nil, fmt.Errorf("creating token request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Content-Type", "application/json")

resp, err := httpClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("creating installation token: %w", err)
return "", "", nil, fmt.Errorf("creating installation token: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
return "", "", fmt.Errorf("creating installation token returned status %d", resp.StatusCode)
return "", "", nil, fmt.Errorf("creating installation token returned status %d", resp.StatusCode)
}

var tokenResp installationTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", "", fmt.Errorf("decoding token response: %w", err)
return "", "", nil, fmt.Errorf("decoding token response: %w", err)
}

if tokenResp.Token == "" {
return "", "", fmt.Errorf("empty installation token returned")
return "", "", nil, fmt.Errorf("empty installation token returned")
}

granted := &GrantedScope{
Permissions: tokenResp.Permissions,
RepoSelection: tokenResp.RepositorySelection,
}
for _, r := range tokenResp.Repositories {
granted.Repos = append(granted.Repos, r.FullName)
}

return tokenResp.Token, tokenResp.ExpiresAt, nil
return tokenResp.Token, tokenResp.ExpiresAt, granted, nil
}
63 changes: 47 additions & 16 deletions internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ type mintRequest struct {

// mintResponse is returned on success.
type mintResponse struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
GrantedRepos []string `json:"granted_repos,omitempty"`
GrantedPerms map[string]string `json:"granted_permissions,omitempty"`
RepoSelection string `json:"repository_selection,omitempty"`
}

// statusResponse is returned by the /v1/status diagnostic endpoint.
Expand Down Expand Up @@ -201,7 +204,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {

org := strings.ToLower(claims.RepositoryOwner)

token, expiresAt, err := h.mintToken(ctx, org, req.Role, req.Repos)
token, expiresAt, granted, err := h.mintToken(ctx, org, req.Role, req.Repos)
if err != nil {
log.Printf("failed to mint token: org=%s role=%s err=%v", org, req.Role, err)
var me *mintError
Expand All @@ -213,15 +216,43 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

log.Printf("minted: org=%s role=%s repo_count=%d", org, req.Role, len(req.Repos))
log.Printf("minted: org=%s role=%s requested_repos=%v source_repo=%s workflow_ref=%s",
org, req.Role, req.Repos, claims.Repository, claims.JobWorkflowRef)
if granted != nil {
log.Printf("granted scope: repos=%v permissions=%v repo_selection=%s",
granted.Repos, granted.Permissions, granted.RepoSelection)
if granted.RepoSelection == "all" {
log.Printf("WARNING: token granted with repository_selection=all (requested specific repos: %v)", req.Repos)
}
requested := RolePermissionsFor(req.Role)
for perm, level := range granted.Permissions {
if reqLevel, ok := requested[perm]; !ok {
log.Printf("WARNING: extra permission granted: %s=%s (not requested)", perm, level)
} else if level != reqLevel {
log.Printf("WARNING: permission level mismatch: %s requested=%s granted=%s", perm, reqLevel, level)
}
}
for perm, reqLevel := range requested {
if _, ok := granted.Permissions[perm]; !ok {
log.Printf("WARNING: requested permission not granted: %s=%s", perm, reqLevel)
}
}
}

resp := mintResponse{
Token: token,
ExpiresAt: expiresAt,
}
if granted != nil {
resp.GrantedRepos = granted.Repos
resp.GrantedPerms = granted.Permissions
resp.RepoSelection = granted.RepoSelection
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(mintResponse{
Token: token,
ExpiresAt: expiresAt,
})
json.NewEncoder(w).Encode(resp)
}

func (h *Handler) handleStatus(w http.ResponseWriter, claims *Claims) {
Expand All @@ -248,15 +279,15 @@ func (h *Handler) handleStatus(w http.ResponseWriter, claims *Claims) {
}
}

func (h *Handler) mintToken(ctx context.Context, org, role string, repos []string) (string, string, error) {
func (h *Handler) mintToken(ctx context.Context, org, role string, repos []string) (string, string, *GrantedScope, error) {
appID, err := h.lookupRoleAppID(org, role)
if err != nil {
return "", "", &mintError{status: http.StatusForbidden, msg: fmt.Sprintf("looking up app ID for role %s: %v", role, err)}
return "", "", nil, &mintError{status: http.StatusForbidden, msg: fmt.Sprintf("looking up app ID for role %s: %v", role, err)}
}

pemData, err := h.pemAccessor.AccessPEM(ctx, org, role)
if err != nil {
return "", "", &mintError{status: http.StatusForbidden, msg: fmt.Sprintf("reading PEM secret for role %s: %v", role, err)}
return "", "", nil, &mintError{status: http.StatusForbidden, msg: fmt.Sprintf("reading PEM secret for role %s: %v", role, err)}
}
defer func() {
for i := range pemData {
Expand All @@ -266,20 +297,20 @@ func (h *Handler) mintToken(ctx context.Context, org, role string, repos []strin

jwt, err := GenerateAppJWT(appID, pemData)
if err != nil {
return "", "", &mintError{status: http.StatusInternalServerError, msg: fmt.Sprintf("generating app JWT: %v", err)}
return "", "", nil, &mintError{status: http.StatusInternalServerError, msg: fmt.Sprintf("generating app JWT: %v", err)}
}

installationID, err := FindInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, org, repos[0])
if err != nil {
return "", "", &mintError{status: http.StatusBadGateway, msg: err.Error()}
return "", "", nil, &mintError{status: http.StatusBadGateway, msg: err.Error()}
}

token, expiresAt, err := CreateInstallationToken(ctx, h.httpClient, h.githubBaseURL, jwt, installationID, role, repos)
token, expiresAt, granted, err := CreateInstallationToken(ctx, h.httpClient, h.githubBaseURL, jwt, installationID, role, repos)
if err != nil {
return "", "", &mintError{status: http.StatusBadGateway, msg: err.Error()}
return "", "", nil, &mintError{status: http.StatusBadGateway, msg: err.Error()}
}

return token, expiresAt, nil
return token, expiresAt, granted, nil
}

func (h *Handler) checkAllowedRole(role string) bool {
Expand Down
45 changes: 34 additions & 11 deletions internal/mintcore/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,23 @@ type installationResponse struct {

// installationTokenResponse is the response from POST /app/installations/{id}/access_tokens.
type installationTokenResponse struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
Permissions map[string]string `json:"permissions,omitempty"`
Repositories []installationTokenRepository `json:"repositories,omitempty"`
RepositorySelection string `json:"repository_selection,omitempty"`
}

// installationTokenRepository is a repo entry in the installation token response.
type installationTokenRepository struct {
FullName string `json:"full_name"`
}

// GrantedScope holds the actual scope GitHub granted for the installation token.
type GrantedScope struct {
Repos []string
Permissions map[string]string
RepoSelection string
}

// canonicalRolePermissions defines the minimum GitHub App permissions per agent role.
Expand Down Expand Up @@ -181,10 +196,10 @@ func FindInstallation(ctx context.Context, httpClient HTTPDoer, githubBaseURL, j

// CreateInstallationToken exchanges a JWT for an installation access token,
// scoped to the given repos and role-specific permissions.
func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, role string, repos []string) (string, string, error) {
func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, role string, repos []string) (string, string, *GrantedScope, error) {
perms := RolePermissionsFor(role)
if perms == nil {
return "", "", fmt.Errorf("no permissions defined for role %q", role)
return "", "", nil, fmt.Errorf("no permissions defined for role %q", role)
}
tokenReqBody := map[string]interface{}{
"repositories": repos,
Expand All @@ -193,37 +208,45 @@ func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBas

tokenReqBytes, err := json.Marshal(tokenReqBody)
if err != nil {
return "", "", fmt.Errorf("marshaling token request: %w", err)
return "", "", nil, fmt.Errorf("marshaling token request: %w", err)
}

reqURL := fmt.Sprintf("%s/app/installations/%d/access_tokens", githubBaseURL, installationID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(tokenReqBytes))
if err != nil {
return "", "", fmt.Errorf("creating token request: %w", err)
return "", "", nil, fmt.Errorf("creating token request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Content-Type", "application/json")

resp, err := httpClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("creating installation token: %w", err)
return "", "", nil, fmt.Errorf("creating installation token: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
return "", "", fmt.Errorf("creating installation token returned status %d", resp.StatusCode)
return "", "", nil, fmt.Errorf("creating installation token returned status %d", resp.StatusCode)
}

var tokenResp installationTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", "", fmt.Errorf("decoding token response: %w", err)
return "", "", nil, fmt.Errorf("decoding token response: %w", err)
}

if tokenResp.Token == "" {
return "", "", fmt.Errorf("empty installation token returned")
return "", "", nil, fmt.Errorf("empty installation token returned")
}

granted := &GrantedScope{
Permissions: tokenResp.Permissions,
RepoSelection: tokenResp.RepositorySelection,
}
for _, r := range tokenResp.Repositories {
granted.Repos = append(granted.Repos, r.FullName)
}

return tokenResp.Token, tokenResp.ExpiresAt, nil
return tokenResp.Token, tokenResp.ExpiresAt, granted, nil
}
4 changes: 2 additions & 2 deletions internal/mintcore/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,14 @@ func TestCreateInstallationToken(t *testing.T) {
}))
defer mockGH.Close()

token, expiresAt, err := CreateInstallationToken(t.Context(), http.DefaultClient, mockGH.URL, "fake-jwt", 42, "coder", []string{"my-repo"})
token, expiresAt, _, err := CreateInstallationToken(t.Context(), http.DefaultClient, mockGH.URL, "fake-jwt", 42, "coder", []string{"my-repo"})
require.NoError(t, err)
assert.Equal(t, "ghs_test_token", token)
assert.Equal(t, "2099-01-01T00:00:00Z", expiresAt)
}

func TestCreateInstallationToken_UnknownRole(t *testing.T) {
_, _, err := CreateInstallationToken(t.Context(), http.DefaultClient, "http://unused", "fake-jwt", 42, "nonexistent", []string{"repo"})
_, _, _, err := CreateInstallationToken(t.Context(), http.DefaultClient, "http://unused", "fake-jwt", 42, "nonexistent", []string{"repo"})
require.Error(t, err)
assert.Contains(t, err.Error(), "no permissions defined")
}
Expand Down
Loading
Loading