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.
382 lines
13 KiB
Go
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 ""
|
|
}
|