Files
CodeNavigator/github.go
T
Andy Dixon 9c7643c616 License under GPLv3
Add SPDX headers to the source, a License section to the README, the
OFL notice for the bundled fonts, and ship LICENSE in the Docker image.
2026-09-20 10:05:06 +01:00

382 lines
13 KiB
Go

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Andy Dixon
package main
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// GitHub App user authorization: the browser is sent to GitHub, the callback exchanges the
// code for a user token held in memory against the session, and clones use that token.
type githubApp struct {
clientID, clientSecret string
slug string // optional; enables the "grant access to more repositories" link
webURL, apiURL string // github.com endpoints, overridden in tests
}
type githubAuth struct {
token string
login string
expires time.Time
state string
stateExpires time.Time
}
var githubClient = &http.Client{Timeout: 15 * time.Second}
func githubAppFromEnv() githubApp {
return githubApp{
clientID: os.Getenv("GITHUB_CLIENT_ID"),
clientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
slug: os.Getenv("GITHUB_APP_SLUG"),
webURL: "https://github.com",
apiURL: "https://api.github.com",
}
}
func (g githubApp) configured() bool { return g.clientID != "" && g.clientSecret != "" }
func (g githubApp) installURL() string {
if g.slug == "" {
return ""
}
return g.webURL + "/apps/" + url.PathEscape(g.slug) + "/installations/new"
}
func (s *Server) githubRoute(w http.ResponseWriter, r *http.Request, action string) {
switch {
case action == "status" && r.Method == http.MethodGet:
auth := s.githubAuthFor(sessionID(nil, r))
writeJSON(w, 200, map[string]any{
"configured": s.github.configured(),
"connected": auth.token != "",
"login": auth.login,
"installUrl": s.github.installURL(),
})
case !s.github.configured():
writeError(w, 404, "GitHub sign-in is not configured")
case action == "login" && r.Method == http.MethodGet:
sid := sessionID(w, r)
state := rand.Text()
s.authMu.Lock()
auth := s.auth[sid]
if auth == nil {
auth = &githubAuth{}
s.auth[sid] = auth
}
auth.state, auth.stateExpires = state, time.Now().Add(10*time.Minute)
s.authMu.Unlock()
query := url.Values{"client_id": {s.github.clientID}, "state": {state}}
http.Redirect(w, r, s.github.webURL+"/login/oauth/authorize?"+query.Encode(), http.StatusFound)
case action == "callback" && r.Method == http.MethodGet:
http.Redirect(w, r, "/?github="+s.githubCallback(r), http.StatusFound)
case action == "logout" && r.Method == http.MethodPost:
s.authMu.Lock()
delete(s.auth, sessionID(nil, r))
s.authMu.Unlock()
writeJSON(w, 200, map[string]bool{"ok": true})
default:
writeError(w, 404, "Unknown GitHub route")
}
}
// githubCallback completes sign-in and returns the outcome shown to the frontend.
func (s *Server) githubCallback(r *http.Request) string {
sid, query := sessionID(nil, r), r.URL.Query()
s.authMu.Lock()
auth := s.auth[sid]
valid := auth != nil && auth.state != "" && time.Now().Before(auth.stateExpires) &&
subtle.ConstantTimeCompare([]byte(auth.state), []byte(query.Get("state"))) == 1
if auth != nil {
auth.state = "" // single use
}
s.authMu.Unlock()
if !valid {
return "error"
}
if query.Get("code") == "" {
return "denied"
}
token, lifetime, err := s.github.exchangeCode(query.Get("code"))
if err != nil {
return "error"
}
login, err := s.github.userLogin(token)
if err != nil {
return "error"
}
s.authMu.Lock()
s.auth[sid] = &githubAuth{token: token, login: login, expires: time.Now().Add(lifetime)}
s.authMu.Unlock()
return "connected"
}
func (g githubApp) exchangeCode(code string) (string, time.Duration, error) {
form := url.Values{"client_id": {g.clientID}, "client_secret": {g.clientSecret}, "code": {code}}
req, _ := http.NewRequest("POST", g.webURL+"/login/oauth/access_token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err := githubClient.Do(req)
if err != nil {
return "", 0, err
}
defer res.Body.Close()
var body struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return "", 0, err
}
if body.AccessToken == "" {
return "", 0, errors.New("token exchange failed: " + body.Error)
}
lifetime := time.Duration(body.ExpiresIn) * time.Second
if lifetime <= 0 {
lifetime = 8 * time.Hour // apps with token expiry disabled; still forget it eventually
}
return body.AccessToken, lifetime, nil
}
func (g githubApp) userLogin(token string) (string, error) {
req, _ := http.NewRequest("GET", g.apiURL+"/user", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
res, err := githubClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var body struct{ Login string }
if res.StatusCode != 200 || json.NewDecoder(res.Body).Decode(&body) != nil || body.Login == "" {
return "", errors.New("could not read GitHub user")
}
return body.Login, nil
}
// githubAuthFor returns a copy of the session's GitHub sign-in; token is "" when absent or expired.
func (s *Server) githubAuthFor(sid string) githubAuth {
s.authMu.Lock()
defer s.authMu.Unlock()
auth := s.auth[sid]
if sid == "" || auth == nil || auth.token == "" || time.Now().After(auth.expires) {
return githubAuth{}
}
return *auth
}
// gitClone builds the clone command. The token travels in environment-provided git config,
// never in the URL or arguments, so it cannot leak through process lists or error output.
func gitClone(ctx context.Context, repoURL, dest, token string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", "--single-branch", "--quiet", "--", repoURL, dest)
// Ignore the host's git config and credential helpers: only the visitor's own token may
// ever authenticate a clone.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=")
if token != "" {
basic := base64.StdEncoding.EncodeToString([]byte("x-access-token:" + token))
cmd.Env = append(cmd.Env,
"GIT_CONFIG_COUNT=2",
"GIT_CONFIG_KEY_1=http.https://github.com/.extraHeader",
"GIT_CONFIG_VALUE_1=Authorization: Basic "+basic)
}
return cmd
}
// cloneRepo clones with a timeout and returns git's output with the token masked.
func cloneRepo(repoURL, dest, token string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), cloneTimeout)
defer cancel()
output, err := gitClone(ctx, repoURL, dest, token).CombinedOutput()
text := string(output)
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
text = "timed out after " + cloneTimeout.String()
}
if token != "" {
text = strings.ReplaceAll(text, token, "***")
}
return text, err
}
// cloneFailure turns git output into a user-facing message and whether signing in (or
// granting the app access) could fix it.
func (g githubApp) cloneFailure(output string, signedIn bool) (message string, authRequired string) {
lower := strings.ToLower(output)
needsAuth := false
for _, hint := range []string{"could not read username", "authentication failed", "repository not found", "terminal prompts disabled", "returned error: 403", "returned error: 401"} {
needsAuth = needsAuth || strings.Contains(lower, hint)
}
switch {
case !needsAuth:
return "git clone failed: " + strings.TrimSpace(output), ""
case !g.configured():
return "Repository not found. It may be private; load it with Open local folder instead.", ""
case !signedIn:
return "This repository is private or does not exist. Sign in with GitHub to open private repositories.", "signin"
default:
return "Your GitHub account cannot open this repository through CodeNavigator. Grant the app access to it, then try again.", "install"
}
}
// Security alert on a file, normalised across code scanning and Dependabot.
type securityAlert struct {
Path string `json:"path"`
Title string `json:"title"`
Severity string `json:"severity"` // critical, high, medium or low
URL string `json:"url"`
Line int `json:"line,omitempty"`
}
type alertSet struct {
Status string `json:"status"` // ok, forbidden (no permission or feature off), unavailable
Alerts []securityAlert `json:"alerts"`
}
// serveAlerts reports open code scanning and Dependabot alerts for a snapshot's repository,
// fetched with the viewer's own GitHub token so they only ever see what they are allowed to.
func (s *Server) serveAlerts(w http.ResponseWriter, r *http.Request, repo string) {
ownerRepo, ok := strings.CutPrefix(repo, "https://github.com/")
if !ok || repo == "" {
writeJSON(w, 200, map[string]any{"available": false, "reason": "not-github"})
return
}
token := s.githubAuthFor(sessionID(nil, r)).token
if !s.github.configured() || token == "" {
writeJSON(w, 200, map[string]any{"available": false, "reason": "signin"})
return
}
var codeScanning, dependabot alertSet
var wg sync.WaitGroup
wg.Go(func() { codeScanning = s.github.codeScanningAlerts(ownerRepo, token) })
wg.Go(func() { dependabot = s.github.dependabotAlerts(ownerRepo, token) })
wg.Wait()
writeJSON(w, 200, map[string]any{"available": true, "codeScanning": codeScanning, "dependabot": dependabot})
}
type codeScanningAlert struct {
HTMLURL string `json:"html_url"`
Rule struct {
ID string `json:"id"`
Severity string `json:"severity"`
SecuritySeverityLevel string `json:"security_severity_level"`
Description string `json:"description"`
} `json:"rule"`
Instance struct {
Location struct {
Path string `json:"path"`
StartLine int `json:"start_line"`
} `json:"location"`
} `json:"most_recent_instance"`
}
func (g githubApp) codeScanningAlerts(ownerRepo, token string) alertSet {
raw, status := fetchPages[codeScanningAlert](g, "/repos/"+ownerRepo+"/code-scanning/alerts?state=open&per_page=100", token)
set := alertSet{Status: status, Alerts: []securityAlert{}}
for _, a := range raw {
severity := strings.ToLower(a.Rule.SecuritySeverityLevel)
if severity == "" {
severity = map[string]string{"error": "high", "warning": "medium", "note": "low"}[strings.ToLower(a.Rule.Severity)]
}
title := a.Rule.Description
if title == "" {
title = a.Rule.ID
}
set.Alerts = append(set.Alerts, securityAlert{a.Instance.Location.Path, title, orLow(severity), a.HTMLURL, a.Instance.Location.StartLine})
}
return set
}
type dependabotAlert struct {
HTMLURL string `json:"html_url"`
Dependency struct {
Package struct {
Name string `json:"name"`
} `json:"package"`
ManifestPath string `json:"manifest_path"`
} `json:"dependency"`
Advisory struct {
Summary string `json:"summary"`
Severity string `json:"severity"`
} `json:"security_advisory"`
}
func (g githubApp) dependabotAlerts(ownerRepo, token string) alertSet {
raw, status := fetchPages[dependabotAlert](g, "/repos/"+ownerRepo+"/dependabot/alerts?state=open&per_page=100", token)
set := alertSet{Status: status, Alerts: []securityAlert{}}
for _, a := range raw {
title := a.Dependency.Package.Name
if a.Advisory.Summary != "" {
title += ": " + a.Advisory.Summary
}
set.Alerts = append(set.Alerts, securityAlert{a.Dependency.ManifestPath, title, orLow(strings.ToLower(a.Advisory.Severity)), a.HTMLURL, 0})
}
return set
}
func orLow(severity string) string {
switch severity {
case "critical", "high", "medium", "low":
return severity
}
return "low"
}
// fetchPages decodes every page of a GitHub list endpoint, following Link rel="next" only while it
// stays on the API host, up to 10 pages. Status is ok, forbidden (no permission or feature off) or
// unavailable (404, network or decode failure).
func fetchPages[T any](g githubApp, path, token string) ([]T, string) {
var all []T
next := g.apiURL + path
for page := 0; next != "" && page < 10; page++ {
req, _ := http.NewRequest("GET", next, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
res, err := githubClient.Do(req)
if err != nil {
return all, "unavailable"
}
var items []T
decodeErr := json.NewDecoder(io.LimitReader(res.Body, 32<<20)).Decode(&items)
res.Body.Close()
switch {
case res.StatusCode == 401 || res.StatusCode == 403:
return all, "forbidden"
case res.StatusCode != 200 || decodeErr != nil:
return all, "unavailable"
}
all = append(all, items...)
if next = nextLink(res.Header.Get("Link")); !strings.HasPrefix(next, g.apiURL+"/") {
next = ""
}
}
return all, "ok"
}
func nextLink(header string) string {
for part := range strings.SplitSeq(header, ",") {
target, rel, ok := strings.Cut(part, ";")
if ok && strings.Contains(rel, `rel="next"`) {
return strings.Trim(strings.TrimSpace(target), "<>")
}
}
return ""
}